mirror of
https://github.com/openai/codex.git
synced 2026-11-09 20:50:33 +00:00
Harden live resume reconstruction
This commit is contained in:
@@ -56,6 +56,7 @@ use codex_protocol::protocol::ViewImageToolCallEvent;
|
||||
use codex_protocol::protocol::WebSearchBeginEvent;
|
||||
use codex_protocol::protocol::WebSearchEndEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
@@ -235,6 +236,9 @@ pub struct ThreadHistoryBuilder {
|
||||
active_change_set: Option<ThreadHistoryChangeSet>,
|
||||
/// Legacy events generated after typed item completion, retained across unrelated live events.
|
||||
pending_completed_item_legacy_events: VecDeque<EventMsg>,
|
||||
completed_item_ids_by_turn: HashMap<String, HashSet<String>>,
|
||||
command_turn_ids: HashMap<String, String>,
|
||||
pending_command_output: HashMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Default for ThreadHistoryBuilder {
|
||||
@@ -253,6 +257,9 @@ impl ThreadHistoryBuilder {
|
||||
next_rollout_index: 0,
|
||||
active_change_set: None,
|
||||
pending_completed_item_legacy_events: VecDeque::new(),
|
||||
completed_item_ids_by_turn: HashMap::new(),
|
||||
command_turn_ids: HashMap::new(),
|
||||
pending_command_output: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,6 +601,10 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
|
||||
fn handle_item_started(&mut self, payload: &ItemStartedEvent) {
|
||||
let item_id = payload.item.id();
|
||||
if self.is_item_completed(&payload.turn_id, &item_id) {
|
||||
return;
|
||||
}
|
||||
match &payload.item {
|
||||
codex_protocol::items::TurnItem::AgentMessage(_)
|
||||
| codex_protocol::items::TurnItem::Plan(_)
|
||||
@@ -619,7 +630,10 @@ impl ThreadHistoryBuilder {
|
||||
&mut self,
|
||||
payload: &codex_protocol::protocol::AgentMessageContentDeltaEvent,
|
||||
) {
|
||||
self.update_item_in_active_turn(&payload.turn_id, &payload.item_id, |item| {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.item_id) {
|
||||
return;
|
||||
}
|
||||
self.update_item_in_turn_id(&payload.turn_id, &payload.item_id, |item| {
|
||||
let ThreadItem::AgentMessage { text, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
@@ -629,7 +643,10 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
|
||||
fn handle_plan_delta(&mut self, payload: &codex_protocol::protocol::PlanDeltaEvent) {
|
||||
self.update_item_in_active_turn(&payload.turn_id, &payload.item_id, |item| {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.item_id) {
|
||||
return;
|
||||
}
|
||||
self.update_item_in_turn_id(&payload.turn_id, &payload.item_id, |item| {
|
||||
let ThreadItem::Plan { text, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
@@ -642,7 +659,10 @@ impl ThreadHistoryBuilder {
|
||||
&mut self,
|
||||
payload: &codex_protocol::protocol::ReasoningContentDeltaEvent,
|
||||
) {
|
||||
self.update_item_in_active_turn(&payload.turn_id, &payload.item_id, |item| {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.item_id) {
|
||||
return;
|
||||
}
|
||||
self.update_item_in_turn_id(&payload.turn_id, &payload.item_id, |item| {
|
||||
let ThreadItem::Reasoning { summary, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
@@ -654,7 +674,10 @@ impl ThreadHistoryBuilder {
|
||||
&mut self,
|
||||
payload: &codex_protocol::protocol::ReasoningRawContentDeltaEvent,
|
||||
) {
|
||||
self.update_item_in_active_turn(&payload.turn_id, &payload.item_id, |item| {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.item_id) {
|
||||
return;
|
||||
}
|
||||
self.update_item_in_turn_id(&payload.turn_id, &payload.item_id, |item| {
|
||||
let ThreadItem::Reasoning { content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
@@ -666,24 +689,40 @@ impl ThreadHistoryBuilder {
|
||||
&mut self,
|
||||
payload: &codex_protocol::protocol::ExecCommandOutputDeltaEvent,
|
||||
) {
|
||||
let Some(turn_id) = self.current_turn.as_ref().map(|turn| turn.id.clone()) else {
|
||||
let Some(turn_id) = self.command_turn_ids.get(&payload.call_id).cloned() else {
|
||||
return;
|
||||
};
|
||||
self.update_item_in_active_turn(&turn_id, &payload.call_id, |item| {
|
||||
if self.is_item_completed(&turn_id, &payload.call_id) {
|
||||
return;
|
||||
}
|
||||
let output = {
|
||||
let pending = self
|
||||
.pending_command_output
|
||||
.entry(payload.call_id.clone())
|
||||
.or_default();
|
||||
pending.extend_from_slice(&payload.chunk);
|
||||
take_decodable_utf8(pending)
|
||||
};
|
||||
let Some(output) = output else {
|
||||
return;
|
||||
};
|
||||
self.update_item_in_turn_id(&turn_id, &payload.call_id, |item| {
|
||||
let ThreadItem::CommandExecution {
|
||||
aggregated_output, ..
|
||||
} = item
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
aggregated_output
|
||||
.get_or_insert_default()
|
||||
.push_str(&String::from_utf8_lossy(&payload.chunk));
|
||||
aggregated_output.get_or_insert_default().push_str(&output);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
|
||||
let item_id = payload.item.id();
|
||||
if self.is_item_completed(&payload.turn_id, &item_id) {
|
||||
return;
|
||||
}
|
||||
match &payload.item {
|
||||
codex_protocol::items::TurnItem::AgentMessage(_)
|
||||
| codex_protocol::items::TurnItem::Reasoning(_) => {
|
||||
@@ -696,6 +735,7 @@ impl ThreadHistoryBuilder {
|
||||
.item
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true),
|
||||
);
|
||||
self.mark_item_completed(&payload.turn_id, &item_id);
|
||||
}
|
||||
codex_protocol::items::TurnItem::Plan(plan) => {
|
||||
if plan.text.is_empty() {
|
||||
@@ -705,12 +745,14 @@ impl ThreadHistoryBuilder {
|
||||
&payload.turn_id,
|
||||
ThreadItem::from(payload.item.clone()),
|
||||
);
|
||||
self.mark_item_completed(&payload.turn_id, &item_id);
|
||||
}
|
||||
codex_protocol::items::TurnItem::Sleep(_) => {
|
||||
self.upsert_item_in_turn_id(
|
||||
&payload.turn_id,
|
||||
ThreadItem::from(payload.item.clone()),
|
||||
);
|
||||
self.mark_item_completed(&payload.turn_id, &item_id);
|
||||
}
|
||||
codex_protocol::items::TurnItem::UserMessage(_)
|
||||
| codex_protocol::items::TurnItem::HookPrompt(_)
|
||||
@@ -754,11 +796,20 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
|
||||
fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.call_id) {
|
||||
return;
|
||||
}
|
||||
self.command_turn_ids
|
||||
.insert(payload.call_id.clone(), payload.turn_id.clone());
|
||||
self.pending_command_output.remove(&payload.call_id);
|
||||
let item = build_command_execution_begin_item(payload);
|
||||
self.upsert_item_in_turn_id(&payload.turn_id, item);
|
||||
}
|
||||
|
||||
fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) {
|
||||
if self.is_item_completed(&payload.turn_id, &payload.call_id) {
|
||||
return;
|
||||
}
|
||||
let item = build_command_execution_end_item(payload);
|
||||
// Command completions can arrive out of order. Unified exec may return
|
||||
// while a PTY is still running, then emit ExecCommandEnd later from a
|
||||
@@ -766,6 +817,10 @@ impl ThreadHistoryBuilder {
|
||||
// newer user turn may already have started. Route by event turn_id so
|
||||
// replay preserves the original turn association.
|
||||
self.upsert_item_in_turn_id(&payload.turn_id, item);
|
||||
self.command_turn_ids
|
||||
.insert(payload.call_id.clone(), payload.turn_id.clone());
|
||||
self.pending_command_output.remove(&payload.call_id);
|
||||
self.mark_item_completed(&payload.turn_id, &payload.call_id);
|
||||
}
|
||||
|
||||
fn handle_guardian_assessment(&mut self, payload: &GuardianAssessmentEvent) {
|
||||
@@ -1491,30 +1546,51 @@ impl ThreadHistoryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_item_in_active_turn(
|
||||
fn update_item_in_turn_id(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
update: impl FnOnce(&mut ThreadItem) -> bool,
|
||||
) {
|
||||
let tracking_changes = self.is_tracking_changes();
|
||||
let changed_item = {
|
||||
let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) else {
|
||||
let changed_item =
|
||||
if let Some(turn) = self.current_turn.as_mut().filter(|turn| turn.id == turn_id) {
|
||||
let Some(item) = turn.items.iter_mut().find(|item| item.id() == item_id) else {
|
||||
return;
|
||||
};
|
||||
if !update(item) {
|
||||
return;
|
||||
}
|
||||
tracking_changes.then(|| (turn.id.clone(), item.clone()))
|
||||
} else if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) {
|
||||
let Some(item) = turn.items.iter_mut().find(|item| item.id() == item_id) else {
|
||||
return;
|
||||
};
|
||||
if !update(item) {
|
||||
return;
|
||||
}
|
||||
tracking_changes.then(|| (turn.id.clone(), item.clone()))
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = turn.items.iter_mut().find(|item| item.id() == item_id) else {
|
||||
return;
|
||||
};
|
||||
if !update(item) {
|
||||
return;
|
||||
}
|
||||
tracking_changes.then(|| (turn.id.clone(), item.clone()))
|
||||
};
|
||||
if let Some((turn_id, item)) = changed_item {
|
||||
self.record_changed_item(turn_id, item);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_item_completed(&self, turn_id: &str, item_id: &str) -> bool {
|
||||
self.completed_item_ids_by_turn
|
||||
.get(turn_id)
|
||||
.is_some_and(|item_ids| item_ids.contains(item_id))
|
||||
}
|
||||
|
||||
fn mark_item_completed(&mut self, turn_id: &str, item_id: &str) {
|
||||
self.completed_item_ids_by_turn
|
||||
.entry(turn_id.to_string())
|
||||
.or_default()
|
||||
.insert(item_id.to_string());
|
||||
}
|
||||
|
||||
fn is_tracking_changes(&self) -> bool {
|
||||
self.active_change_set.is_some()
|
||||
}
|
||||
@@ -1586,13 +1662,42 @@ fn append_indexed_delta(parts: &mut Vec<String>, index: i64, delta: &str) -> boo
|
||||
let Ok(index) = usize::try_from(index) else {
|
||||
return false;
|
||||
};
|
||||
if parts.len() <= index {
|
||||
parts.resize(index + 1, String::new());
|
||||
if index > parts.len() {
|
||||
return false;
|
||||
}
|
||||
if index == parts.len() {
|
||||
parts.push(String::new());
|
||||
}
|
||||
parts[index].push_str(delta);
|
||||
true
|
||||
}
|
||||
|
||||
fn take_decodable_utf8(bytes: &mut Vec<u8>) -> Option<String> {
|
||||
let mut output = String::new();
|
||||
loop {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(text) => {
|
||||
output.push_str(text);
|
||||
bytes.clear();
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
let valid_up_to = error.valid_up_to();
|
||||
if valid_up_to > 0 {
|
||||
output.push_str(&String::from_utf8_lossy(&bytes[..valid_up_to]));
|
||||
bytes.drain(..valid_up_to);
|
||||
}
|
||||
let Some(error_len) = error.error_len() else {
|
||||
break;
|
||||
};
|
||||
output.push('\u{FFFD}');
|
||||
bytes.drain(..error_len);
|
||||
}
|
||||
}
|
||||
}
|
||||
(!output.is_empty()).then_some(output)
|
||||
}
|
||||
|
||||
fn completed_item_legacy_events_match(expected: &EventMsg, actual: &EventMsg) -> bool {
|
||||
match (expected, actual) {
|
||||
(EventMsg::AgentMessage(expected), EventMsg::AgentMessage(actual)) => {
|
||||
@@ -1717,14 +1822,16 @@ impl From<&PendingTurn> for Turn {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_history_live_tests.rs"]
|
||||
mod live_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::v2::CommandExecutionSource;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
|
||||
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem as CoreAgentMessageItem;
|
||||
use codex_protocol::items::HookPromptFragment as CoreHookPromptFragment;
|
||||
use codex_protocol::items::SleepItem as CoreSleepItem;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
@@ -1735,7 +1842,6 @@ mod tests {
|
||||
use codex_protocol::models::MessagePhase as CoreMessagePhase;
|
||||
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AgentReasoningEvent;
|
||||
use codex_protocol::protocol::AgentReasoningRawContentEvent;
|
||||
@@ -3351,111 +3457,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_turn_snapshot_accumulates_agent_message_deltas() {
|
||||
let thread_id = ThreadId::new();
|
||||
let turn_id = "turn-1";
|
||||
let mut builder = ThreadHistoryBuilder::new();
|
||||
for event in [
|
||||
turn_started(turn_id),
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
thread_id,
|
||||
turn_id: turn_id.to_string(),
|
||||
item: CoreTurnItem::AgentMessage(CoreAgentMessageItem {
|
||||
id: "msg-1".to_string(),
|
||||
content: Vec::new(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
started_at_ms: 0,
|
||||
}),
|
||||
EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: "msg-1".to_string(),
|
||||
delta: "hello ".to_string(),
|
||||
}),
|
||||
EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent {
|
||||
thread_id: thread_id.to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: "msg-1".to_string(),
|
||||
delta: "world".to_string(),
|
||||
}),
|
||||
] {
|
||||
builder.handle_event(&event);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
builder.active_turn_snapshot().expect("active turn").items,
|
||||
vec![ThreadItem::AgentMessage {
|
||||
id: "msg-1".to_string(),
|
||||
text: "hello world".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn completed_agent_message_replaces_streamed_item_without_legacy_duplicate() {
|
||||
let thread_id = ThreadId::new();
|
||||
let turn_id = "turn-1";
|
||||
let completed_item = CoreTurnItem::AgentMessage(CoreAgentMessageItem {
|
||||
id: "msg-1".to_string(),
|
||||
content: vec![CoreAgentMessageContent::Text {
|
||||
text: "hello".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
});
|
||||
let mut builder = ThreadHistoryBuilder::new();
|
||||
for event in [
|
||||
turn_started(turn_id),
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
thread_id,
|
||||
turn_id: turn_id.to_string(),
|
||||
item: CoreTurnItem::AgentMessage(CoreAgentMessageItem {
|
||||
id: "msg-1".to_string(),
|
||||
content: Vec::new(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
started_at_ms: 0,
|
||||
}),
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id,
|
||||
turn_id: turn_id.to_string(),
|
||||
item: completed_item.clone(),
|
||||
completed_at_ms: 0,
|
||||
}),
|
||||
completed_item
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("agent message should produce one legacy event"),
|
||||
] {
|
||||
builder.handle_event(&event);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
builder.active_turn_snapshot().expect("active turn").items,
|
||||
vec![ThreadItem::AgentMessage {
|
||||
id: "msg-1".to_string(),
|
||||
text: "hello".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
fn turn_started(turn_id: &str) -> EventMsg {
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_turn_complete_does_not_close_active_turn() {
|
||||
let events = vec![
|
||||
|
||||
@@ -0,0 +1,958 @@
|
||||
use super::*;
|
||||
use crate::protocol::v2::CommandAction;
|
||||
use crate::protocol::v2::CommandExecutionSource;
|
||||
use crate::protocol::v2::CommandExecutionStatus;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
|
||||
use codex_protocol::items::AgentMessageItem as CoreAgentMessageItem;
|
||||
use codex_protocol::items::PlanItem as CorePlanItem;
|
||||
use codex_protocol::items::ReasoningItem as CoreReasoningItem;
|
||||
use codex_protocol::items::TurnItem as CoreTurnItem;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use codex_protocol::protocol::AgentMessageContentDeltaEvent;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
use codex_protocol::protocol::ExecCommandEndEvent;
|
||||
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
|
||||
use codex_protocol::protocol::ExecCommandSource;
|
||||
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
|
||||
use codex_protocol::protocol::ExecOutputStream;
|
||||
use codex_protocol::protocol::ItemCompletedEvent;
|
||||
use codex_protocol::protocol::ItemStartedEvent;
|
||||
use codex_protocol::protocol::PlanDeltaEvent;
|
||||
use codex_protocol::protocol::ReasoningContentDeltaEvent;
|
||||
use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
|
||||
use codex_protocol::protocol::TokenCountEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
const TURN_A: &str = "turn-a";
|
||||
const TURN_B: &str = "turn-b";
|
||||
const AGENT_ID: &str = "agent-1";
|
||||
const PLAN_ID: &str = "plan-1";
|
||||
const REASONING_ID: &str = "reasoning-1";
|
||||
const COMMAND_ID: &str = "command-1";
|
||||
|
||||
#[test]
|
||||
fn live_agent_message_state_is_cumulative() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let started_item = core_agent(AGENT_ID, &[], Some(MessagePhase::Commentary));
|
||||
let mut expected = vec![agent_item(AGENT_ID, "", Some(MessagePhase::Commentary))];
|
||||
|
||||
apply_single_item(&mut builder, item_started(TURN_A, started_item), &expected);
|
||||
expected[0] = agent_item(AGENT_ID, "hello ", Some(MessagePhase::Commentary));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
agent_delta(TURN_A, AGENT_ID, "hello "),
|
||||
&expected,
|
||||
);
|
||||
apply_no_change(&mut builder, EventMsg::TokenCount(empty_token_count()));
|
||||
expected[0] = agent_item(AGENT_ID, "hello world", Some(MessagePhase::Commentary));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
agent_delta(TURN_A, AGENT_ID, "world"),
|
||||
&expected,
|
||||
);
|
||||
|
||||
let completed_item = core_agent(
|
||||
AGENT_ID,
|
||||
&["canonical ", "answer"],
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
expected[0] = agent_item(
|
||||
AGENT_ID,
|
||||
"canonical answer",
|
||||
Some(MessagePhase::FinalAnswer),
|
||||
);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_completed(TURN_A, completed_item.clone()),
|
||||
&expected,
|
||||
);
|
||||
|
||||
for legacy_event in completed_item.as_legacy_events(/*show_raw_agent_reasoning*/ true) {
|
||||
apply_no_change(&mut builder, legacy_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_plan_state_is_cumulative_and_completion_is_authoritative() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let mut expected = vec![plan_item(PLAN_ID, "")];
|
||||
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_started(TURN_A, core_plan(PLAN_ID, "")),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = plan_item(PLAN_ID, "draft ");
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
plan_delta(TURN_A, PLAN_ID, "draft "),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = plan_item(PLAN_ID, "draft plan");
|
||||
apply_single_item(&mut builder, plan_delta(TURN_A, PLAN_ID, "plan"), &expected);
|
||||
|
||||
expected[0] = plan_item(PLAN_ID, "canonical plan");
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_completed(TURN_A, core_plan(PLAN_ID, "canonical plan")),
|
||||
&expected,
|
||||
);
|
||||
apply_no_change(&mut builder, item_completed(TURN_A, core_plan(PLAN_ID, "")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reasoning_state_is_cumulative_by_index() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let mut expected = vec![reasoning_item(REASONING_ID, &[], &[])];
|
||||
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_started(TURN_A, core_reasoning(REASONING_ID, &[], &[])),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = reasoning_item(REASONING_ID, &["think"], &[]);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
reasoning_summary_delta(TURN_A, REASONING_ID, 0, "think"),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = reasoning_item(REASONING_ID, &["think more"], &[]);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
reasoning_summary_delta(TURN_A, REASONING_ID, 0, " more"),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = reasoning_item(REASONING_ID, &["think more", "next"], &[]);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
reasoning_summary_delta(TURN_A, REASONING_ID, 1, "next"),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = reasoning_item(REASONING_ID, &["think more", "next"], &["raw"]);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
reasoning_raw_delta(TURN_A, REASONING_ID, 0, "raw"),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = reasoning_item(REASONING_ID, &["think more", "next"], &["raw detail"]);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
reasoning_raw_delta(TURN_A, REASONING_ID, 0, " detail"),
|
||||
&expected,
|
||||
);
|
||||
|
||||
let completed_item = core_reasoning(
|
||||
REASONING_ID,
|
||||
&["canonical summary", "second summary"],
|
||||
&["canonical raw", "second raw"],
|
||||
);
|
||||
expected[0] = reasoning_item(
|
||||
REASONING_ID,
|
||||
&["canonical summary", "second summary"],
|
||||
&["canonical raw", "second raw"],
|
||||
);
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_completed(TURN_A, completed_item.clone()),
|
||||
&expected,
|
||||
);
|
||||
|
||||
let mut legacy_events = completed_item.as_legacy_events(/*show_raw_agent_reasoning*/ true);
|
||||
legacy_events.reverse();
|
||||
for legacy_event in legacy_events {
|
||||
apply_no_change(&mut builder, legacy_event);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_command_state_is_cumulative_by_chunk() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let mut expected = vec![running_command(/*aggregated_output*/ None)];
|
||||
|
||||
apply_single_item(&mut builder, command_begin(TURN_A), &expected);
|
||||
expected[0] = running_command(Some("out "));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
command_delta(ExecOutputStream::Stdout, b"out "),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = running_command(Some("out warning "));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
command_delta(ExecOutputStream::Stderr, b"warning "),
|
||||
&expected,
|
||||
);
|
||||
expected[0] = running_command(Some("out warning done"));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
command_delta(ExecOutputStream::Stdout, b"done"),
|
||||
&expected,
|
||||
);
|
||||
|
||||
expected[0] = completed_command("canonical output");
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
command_end(TURN_A, "canonical output"),
|
||||
&expected,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_live_events_are_noops() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let expected = vec![
|
||||
agent_item(AGENT_ID, "", None),
|
||||
plan_item(PLAN_ID, ""),
|
||||
reasoning_item(REASONING_ID, &[], &[]),
|
||||
running_command(/*aggregated_output*/ None),
|
||||
];
|
||||
for event in [
|
||||
item_started(TURN_A, core_agent(AGENT_ID, &[], None)),
|
||||
item_started(TURN_A, core_plan(PLAN_ID, "")),
|
||||
item_started(TURN_A, core_reasoning(REASONING_ID, &[], &[])),
|
||||
command_begin(TURN_A),
|
||||
] {
|
||||
builder.handle_event(&event);
|
||||
}
|
||||
assert_active_turn(&builder, TURN_A, expected.clone(), TurnStatus::InProgress);
|
||||
|
||||
for event in [
|
||||
agent_delta(TURN_B, AGENT_ID, "wrong turn"),
|
||||
plan_delta(TURN_A, "unknown-plan", "wrong item"),
|
||||
reasoning_summary_delta(TURN_A, AGENT_ID, 0, "wrong type"),
|
||||
reasoning_raw_delta(TURN_A, REASONING_ID, -1, "negative index"),
|
||||
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: "unknown-command".to_string(),
|
||||
stream: ExecOutputStream::Stdout,
|
||||
chunk: b"wrong item".to_vec(),
|
||||
}),
|
||||
] {
|
||||
assert_eq!(
|
||||
apply_no_change_with_expected(&mut builder, event, expected.clone()),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonmatching_agent_legacy_event_is_preserved() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed_item = core_agent(AGENT_ID, &["same text"], Some(MessagePhase::FinalAnswer));
|
||||
let completed = agent_item(AGENT_ID, "same text", Some(MessagePhase::FinalAnswer));
|
||||
apply_single_item(
|
||||
&mut builder,
|
||||
item_completed(TURN_A, completed_item.clone()),
|
||||
std::slice::from_ref(&completed),
|
||||
);
|
||||
|
||||
let commentary = agent_item("item-1", "same text", Some(MessagePhase::Commentary));
|
||||
assert_eq!(
|
||||
apply_with_expected(
|
||||
&mut builder,
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "same text".to_string(),
|
||||
phase: Some(MessagePhase::Commentary),
|
||||
memory_citation: None,
|
||||
}),
|
||||
vec![completed.clone(), commentary.clone()],
|
||||
),
|
||||
single_item_change(TURN_A, commentary.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
apply_no_change_with_expected(
|
||||
&mut builder,
|
||||
completed_item
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("agent completion should generate one legacy event"),
|
||||
vec![completed, commentary],
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_expectations_clear_at_turn_boundary() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed_item = core_agent(AGENT_ID, &["repeated text"], None);
|
||||
builder.handle_event(&item_completed(TURN_A, completed_item));
|
||||
builder.handle_event(&turn_completed(TURN_A));
|
||||
builder.handle_event(&turn_started(TURN_B));
|
||||
|
||||
let expected = vec![agent_item("item-1", "repeated text", None)];
|
||||
assert_eq!(
|
||||
apply_with_expected(
|
||||
&mut builder,
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "repeated text".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
expected.clone(),
|
||||
),
|
||||
single_item_change(TURN_B, expected[0].clone())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_completions_consume_only_their_legacy_events() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let first = core_agent("agent-1", &["first"], None);
|
||||
let second = core_agent("agent-2", &["second"], None);
|
||||
let expected = vec![
|
||||
agent_item("agent-1", "first", None),
|
||||
agent_item("agent-2", "second", None),
|
||||
];
|
||||
builder.handle_event(&item_completed(TURN_A, first.clone()));
|
||||
builder.handle_event(&item_completed(TURN_A, second.clone()));
|
||||
assert_active_turn(&builder, TURN_A, expected.clone(), TurnStatus::InProgress);
|
||||
|
||||
let second_legacy = second
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("agent completion should generate one legacy event");
|
||||
assert_eq!(
|
||||
apply_no_change_with_expected(&mut builder, second_legacy, expected.clone()),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_eq!(
|
||||
apply_no_change_with_expected(
|
||||
&mut builder,
|
||||
EventMsg::TokenCount(empty_token_count()),
|
||||
expected.clone(),
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
let first_legacy = first
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("agent completion should generate one legacy event");
|
||||
assert_eq!(
|
||||
apply_no_change_with_expected(&mut builder, first_legacy, expected),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_completion_updates_only_its_original_turn() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&item_started(
|
||||
TURN_A,
|
||||
core_agent(AGENT_ID, &[], Some(MessagePhase::Commentary)),
|
||||
));
|
||||
builder.handle_event(&turn_completed(TURN_A));
|
||||
builder.handle_event(&turn_started(TURN_B));
|
||||
|
||||
let completed_item = core_agent(AGENT_ID, &["final"], Some(MessagePhase::FinalAnswer));
|
||||
let completed = agent_item(AGENT_ID, "final", Some(MessagePhase::FinalAnswer));
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&item_completed(TURN_A, completed_item.clone())),
|
||||
single_item_change(TURN_A, completed.clone())
|
||||
);
|
||||
assert_turn(&builder, TURN_A, vec![completed], TurnStatus::Completed);
|
||||
assert_active_turn(&builder, TURN_B, Vec::new(), TurnStatus::InProgress);
|
||||
|
||||
for legacy_event in completed_item.as_legacy_events(/*show_raw_agent_reasoning*/ true) {
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&legacy_event),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_B, Vec::new(), TurnStatus::InProgress);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_live_changes_coalesce_to_authoritative_completion() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed_item = core_agent(AGENT_ID, &["canonical"], None);
|
||||
let mut rollout_items = vec![
|
||||
RolloutItem::EventMsg(item_started(TURN_A, core_agent(AGENT_ID, &[], None))),
|
||||
RolloutItem::EventMsg(agent_delta(TURN_A, AGENT_ID, "partial")),
|
||||
RolloutItem::EventMsg(item_completed(TURN_A, completed_item.clone())),
|
||||
];
|
||||
rollout_items.extend(
|
||||
completed_item
|
||||
.as_legacy_events(/*show_raw_agent_reasoning*/ true)
|
||||
.into_iter()
|
||||
.map(RolloutItem::EventMsg),
|
||||
);
|
||||
let completed = agent_item(AGENT_ID, "canonical", None);
|
||||
|
||||
assert_eq!(
|
||||
builder.handle_rollout_items_with_changes(&rollout_items),
|
||||
single_item_change(TURN_A, completed.clone())
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, vec![completed], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_agent_message_ignores_late_delta() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed = agent_item(AGENT_ID, "final", None);
|
||||
builder.handle_event(&item_completed(
|
||||
TURN_A,
|
||||
core_agent(AGENT_ID, &["final"], None),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(&mut builder, agent_delta(TURN_A, AGENT_ID, " late")),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, vec![completed], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_plan_ignores_late_delta() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed = plan_item(PLAN_ID, "final");
|
||||
builder.handle_event(&item_completed(TURN_A, core_plan(PLAN_ID, "final")));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(&mut builder, plan_delta(TURN_A, PLAN_ID, " late")),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, vec![completed], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_reasoning_ignores_late_deltas() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed = reasoning_item(REASONING_ID, &["final summary"], &["final raw"]);
|
||||
builder.handle_event(&item_completed(
|
||||
TURN_A,
|
||||
core_reasoning(REASONING_ID, &["final summary"], &["final raw"]),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(
|
||||
&mut builder,
|
||||
reasoning_summary_delta(TURN_A, REASONING_ID, 0, " late")
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_eq!(
|
||||
apply_no_change(
|
||||
&mut builder,
|
||||
reasoning_raw_delta(TURN_A, REASONING_ID, 0, " late")
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, vec![completed], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_command_ignores_late_begin_and_output() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&command_end(TURN_A, "final"));
|
||||
|
||||
apply_no_change(&mut builder, command_begin(TURN_A));
|
||||
assert_eq!(
|
||||
apply_no_change(
|
||||
&mut builder,
|
||||
command_delta(ExecOutputStream::Stdout, b" late")
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![completed_command("final")],
|
||||
TurnStatus::InProgress,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_command_ignores_duplicate_end() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&command_end(TURN_A, "final"));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(&mut builder, command_end(TURN_A, "stale duplicate")),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![completed_command("final")],
|
||||
TurnStatus::InProgress,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_item_ignores_late_start() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let completed = agent_item(AGENT_ID, "final", None);
|
||||
builder.handle_event(&item_completed(
|
||||
TURN_A,
|
||||
core_agent(AGENT_ID, &["final"], None),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(
|
||||
&mut builder,
|
||||
item_started(TURN_A, core_agent(AGENT_ID, &[], None))
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, vec![completed], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_preserves_utf8_across_every_chunk_boundary() {
|
||||
let text = "a😀éz";
|
||||
let bytes = text.as_bytes();
|
||||
|
||||
for first_split in 0..=bytes.len() {
|
||||
for second_split in first_split..=bytes.len() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&command_begin(TURN_A));
|
||||
for chunk in [
|
||||
&bytes[..first_split],
|
||||
&bytes[first_split..second_split],
|
||||
&bytes[second_split..],
|
||||
] {
|
||||
builder.handle_event(&command_delta(ExecOutputStream::Stdout, chunk));
|
||||
}
|
||||
assert_active_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![running_command(Some(text))],
|
||||
TurnStatus::InProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_output_replaces_invalid_utf8_without_dropping_surrounding_bytes() {
|
||||
for (chunks, expected) in [
|
||||
(vec![b"a".to_vec(), vec![0xFF], b"b".to_vec()], "a�b"),
|
||||
(vec![vec![0xF0], vec![0x28, 0x8C], vec![0x28]], "�(�("),
|
||||
] {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&command_begin(TURN_A));
|
||||
for chunk in chunks {
|
||||
builder.handle_event(&command_delta(ExecOutputStream::Stdout, &chunk));
|
||||
}
|
||||
assert_active_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![running_command(Some(expected))],
|
||||
TurnStatus::InProgress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_item_tracking_is_scoped_to_its_turn() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&item_completed(
|
||||
TURN_A,
|
||||
core_agent(AGENT_ID, &["first"], None),
|
||||
));
|
||||
builder.handle_event(&turn_completed(TURN_A));
|
||||
builder.handle_event(&turn_started(TURN_B));
|
||||
|
||||
let second = agent_item(AGENT_ID, "second", None);
|
||||
builder.handle_event(&item_started(TURN_B, core_agent(AGENT_ID, &[], None)));
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&agent_delta(TURN_B, AGENT_ID, "second")),
|
||||
single_item_change(TURN_B, second.clone())
|
||||
);
|
||||
assert_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![agent_item(AGENT_ID, "first", None)],
|
||||
TurnStatus::Completed,
|
||||
);
|
||||
assert_active_turn(&builder, TURN_B, vec![second], TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_command_output_updates_original_turn() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
builder.handle_event(&command_begin(TURN_A));
|
||||
builder.handle_event(&turn_completed(TURN_A));
|
||||
builder.handle_event(&turn_started(TURN_B));
|
||||
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&command_delta(ExecOutputStream::Stdout, b"late")),
|
||||
single_item_change(TURN_A, running_command(Some("late")))
|
||||
);
|
||||
assert_turn(
|
||||
&builder,
|
||||
TURN_A,
|
||||
vec![running_command(Some("late"))],
|
||||
TurnStatus::Completed,
|
||||
);
|
||||
assert_active_turn(&builder, TURN_B, Vec::new(), TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_delta_rejects_sparse_index() {
|
||||
let mut builder = started_builder(TURN_A);
|
||||
let expected = vec![reasoning_item(REASONING_ID, &[], &[])];
|
||||
builder.handle_event(&item_started(
|
||||
TURN_A,
|
||||
core_reasoning(REASONING_ID, &[], &[]),
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
apply_no_change(
|
||||
&mut builder,
|
||||
reasoning_summary_delta(TURN_A, REASONING_ID, 2, "sparse")
|
||||
),
|
||||
ThreadHistoryChangeSet::default()
|
||||
);
|
||||
assert_active_turn(&builder, TURN_A, expected, TurnStatus::InProgress);
|
||||
}
|
||||
|
||||
fn started_builder(turn_id: &str) -> ThreadHistoryBuilder {
|
||||
let mut builder = ThreadHistoryBuilder::new();
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&turn_started(turn_id)),
|
||||
ThreadHistoryChangeSet {
|
||||
changed_items: Vec::new(),
|
||||
changed_turns: vec![ThreadHistoryTurnChange {
|
||||
turn_id: turn_id.to_string(),
|
||||
status: TurnStatus::InProgress,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}],
|
||||
removed_turn_ids: Vec::new(),
|
||||
}
|
||||
);
|
||||
assert_active_turn(&builder, turn_id, Vec::new(), TurnStatus::InProgress);
|
||||
builder
|
||||
}
|
||||
|
||||
fn apply_single_item(
|
||||
builder: &mut ThreadHistoryBuilder,
|
||||
event: EventMsg,
|
||||
expected_items: &[ThreadItem],
|
||||
) {
|
||||
let expected_item = expected_items
|
||||
.first()
|
||||
.expect("event should leave one materialized item");
|
||||
assert_eq!(expected_items.len(), 1);
|
||||
let turn_id = builder.active_turn_snapshot().expect("active turn").id;
|
||||
assert_eq!(
|
||||
builder.handle_event_with_changes(&event),
|
||||
single_item_change(&turn_id, expected_item.clone())
|
||||
);
|
||||
assert_active_turn(
|
||||
builder,
|
||||
&turn_id,
|
||||
expected_items.to_vec(),
|
||||
TurnStatus::InProgress,
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_with_expected(
|
||||
builder: &mut ThreadHistoryBuilder,
|
||||
event: EventMsg,
|
||||
expected_items: Vec<ThreadItem>,
|
||||
) -> ThreadHistoryChangeSet {
|
||||
let changes = builder.handle_event_with_changes(&event);
|
||||
let turn_id = builder.active_turn_snapshot().expect("active turn").id;
|
||||
assert_active_turn(builder, &turn_id, expected_items, TurnStatus::InProgress);
|
||||
changes
|
||||
}
|
||||
|
||||
fn apply_no_change(builder: &mut ThreadHistoryBuilder, event: EventMsg) -> ThreadHistoryChangeSet {
|
||||
let expected_items = builder.active_turn_snapshot().expect("active turn").items;
|
||||
apply_no_change_with_expected(builder, event, expected_items)
|
||||
}
|
||||
|
||||
fn apply_no_change_with_expected(
|
||||
builder: &mut ThreadHistoryBuilder,
|
||||
event: EventMsg,
|
||||
expected_items: Vec<ThreadItem>,
|
||||
) -> ThreadHistoryChangeSet {
|
||||
let changes = apply_with_expected(builder, event, expected_items);
|
||||
assert_eq!(changes, ThreadHistoryChangeSet::default());
|
||||
changes
|
||||
}
|
||||
|
||||
fn assert_active_turn(
|
||||
builder: &ThreadHistoryBuilder,
|
||||
turn_id: &str,
|
||||
items: Vec<ThreadItem>,
|
||||
status: TurnStatus,
|
||||
) {
|
||||
assert_eq!(
|
||||
builder.active_turn_snapshot(),
|
||||
Some(turn(turn_id, items, status))
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_turn(
|
||||
builder: &ThreadHistoryBuilder,
|
||||
turn_id: &str,
|
||||
items: Vec<ThreadItem>,
|
||||
status: TurnStatus,
|
||||
) {
|
||||
assert_eq!(
|
||||
builder.turn_snapshot(turn_id),
|
||||
Some(turn(turn_id, items, status))
|
||||
);
|
||||
}
|
||||
|
||||
fn turn(turn_id: &str, items: Vec<ThreadItem>, status: TurnStatus) -> Turn {
|
||||
Turn {
|
||||
id: turn_id.to_string(),
|
||||
items,
|
||||
items_view: TurnItemsView::Full,
|
||||
status,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn single_item_change(turn_id: &str, item: ThreadItem) -> ThreadHistoryChangeSet {
|
||||
ThreadHistoryChangeSet {
|
||||
changed_items: vec![ThreadHistoryItemChange {
|
||||
turn_id: turn_id.to_string(),
|
||||
item,
|
||||
}],
|
||||
changed_turns: Vec::new(),
|
||||
removed_turn_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_started(turn_id: &str) -> EventMsg {
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn turn_completed(turn_id: &str) -> EventMsg {
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn item_started(turn_id: &str, item: CoreTurnItem) -> EventMsg {
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item,
|
||||
started_at_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn item_completed(turn_id: &str, item: CoreTurnItem) -> EventMsg {
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
thread_id: ThreadId::new(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item,
|
||||
completed_at_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn core_agent(id: &str, fragments: &[&str], phase: Option<MessagePhase>) -> CoreTurnItem {
|
||||
CoreTurnItem::AgentMessage(CoreAgentMessageItem {
|
||||
id: id.to_string(),
|
||||
content: fragments
|
||||
.iter()
|
||||
.map(|text| CoreAgentMessageContent::Text {
|
||||
text: (*text).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
phase,
|
||||
memory_citation: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn core_plan(id: &str, text: &str) -> CoreTurnItem {
|
||||
CoreTurnItem::Plan(CorePlanItem {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn core_reasoning(id: &str, summary: &[&str], content: &[&str]) -> CoreTurnItem {
|
||||
CoreTurnItem::Reasoning(CoreReasoningItem {
|
||||
id: id.to_string(),
|
||||
summary_text: summary.iter().map(ToString::to_string).collect(),
|
||||
raw_content: content.iter().map(ToString::to_string).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_delta(turn_id: &str, item_id: &str, delta: &str) -> EventMsg {
|
||||
EventMsg::AgentMessageContentDelta(AgentMessageContentDeltaEvent {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: delta.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn plan_delta(turn_id: &str, item_id: &str, delta: &str) -> EventMsg {
|
||||
EventMsg::PlanDelta(PlanDeltaEvent {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: delta.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn reasoning_summary_delta(
|
||||
turn_id: &str,
|
||||
item_id: &str,
|
||||
summary_index: i64,
|
||||
delta: &str,
|
||||
) -> EventMsg {
|
||||
EventMsg::ReasoningContentDelta(ReasoningContentDeltaEvent {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: delta.to_string(),
|
||||
summary_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn reasoning_raw_delta(turn_id: &str, item_id: &str, content_index: i64, delta: &str) -> EventMsg {
|
||||
EventMsg::ReasoningRawContentDelta(ReasoningRawContentDeltaEvent {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: turn_id.to_string(),
|
||||
item_id: item_id.to_string(),
|
||||
delta: delta.to_string(),
|
||||
content_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_begin(turn_id: &str) -> EventMsg {
|
||||
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
|
||||
call_id: COMMAND_ID.to_string(),
|
||||
process_id: Some("process-1".to_string()),
|
||||
turn_id: turn_id.to_string(),
|
||||
started_at_ms: 0,
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
parsed_cmd: vec![ParsedCommand::Unknown {
|
||||
cmd: "echo hello".to_string(),
|
||||
}],
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn command_delta(stream: ExecOutputStream, chunk: &[u8]) -> EventMsg {
|
||||
EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent {
|
||||
call_id: COMMAND_ID.to_string(),
|
||||
stream,
|
||||
chunk: chunk.to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn command_end(turn_id: &str, aggregated_output: &str) -> EventMsg {
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id: COMMAND_ID.to_string(),
|
||||
process_id: Some("process-1".to_string()),
|
||||
turn_id: turn_id.to_string(),
|
||||
completed_at_ms: 0,
|
||||
command: vec!["echo".to_string(), "hello".to_string()],
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
parsed_cmd: vec![ParsedCommand::Unknown {
|
||||
cmd: "echo hello".to_string(),
|
||||
}],
|
||||
source: ExecCommandSource::Agent,
|
||||
interaction_input: None,
|
||||
stdout: aggregated_output.to_string(),
|
||||
stderr: String::new(),
|
||||
aggregated_output: aggregated_output.to_string(),
|
||||
exit_code: 0,
|
||||
duration: Duration::from_millis(5),
|
||||
formatted_output: aggregated_output.to_string(),
|
||||
status: CoreExecCommandStatus::Completed,
|
||||
})
|
||||
}
|
||||
|
||||
fn agent_item(id: &str, text: &str, phase: Option<MessagePhase>) -> ThreadItem {
|
||||
ThreadItem::AgentMessage {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
phase,
|
||||
memory_citation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_item(id: &str, text: &str) -> ThreadItem {
|
||||
ThreadItem::Plan {
|
||||
id: id.to_string(),
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_item(id: &str, summary: &[&str], content: &[&str]) -> ThreadItem {
|
||||
ThreadItem::Reasoning {
|
||||
id: id.to_string(),
|
||||
summary: summary.iter().map(ToString::to_string).collect(),
|
||||
content: content.iter().map(ToString::to_string).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn running_command(aggregated_output: Option<&str>) -> ThreadItem {
|
||||
ThreadItem::CommandExecution {
|
||||
id: COMMAND_ID.to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("process-1".to_string()),
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::InProgress,
|
||||
command_actions: vec![CommandAction::Unknown {
|
||||
command: "echo hello".to_string(),
|
||||
}],
|
||||
aggregated_output: aggregated_output.map(ToString::to_string),
|
||||
exit_code: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn completed_command(aggregated_output: &str) -> ThreadItem {
|
||||
ThreadItem::CommandExecution {
|
||||
id: COMMAND_ID.to_string(),
|
||||
command: "echo hello".to_string(),
|
||||
cwd: test_path_buf("/tmp").abs().into(),
|
||||
process_id: Some("process-1".to_string()),
|
||||
source: CommandExecutionSource::Agent,
|
||||
status: CommandExecutionStatus::Completed,
|
||||
command_actions: vec![CommandAction::Unknown {
|
||||
command: "echo hello".to_string(),
|
||||
}],
|
||||
aggregated_output: Some(aggregated_output.to_string()),
|
||||
exit_code: Some(0),
|
||||
duration_ms: Some(5),
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_token_count() -> TokenCountEvent {
|
||||
TokenCountEvent {
|
||||
info: None,
|
||||
rate_limits: None,
|
||||
}
|
||||
}
|
||||
@@ -819,13 +819,31 @@ pub(super) fn set_thread_status_and_interrupt_stale_turns(
|
||||
loaded_status: ThreadStatus,
|
||||
has_live_in_progress_turn: bool,
|
||||
) {
|
||||
let status = resolve_thread_status(loaded_status, has_live_in_progress_turn);
|
||||
if !matches!(status, ThreadStatus::Active { .. }) {
|
||||
for turn in &mut thread.turns {
|
||||
if matches!(turn.status, TurnStatus::InProgress) {
|
||||
turn.status = TurnStatus::Interrupted;
|
||||
}
|
||||
}
|
||||
let mut status = resolve_thread_status(loaded_status, has_live_in_progress_turn);
|
||||
if !has_live_in_progress_turn
|
||||
&& matches!(&status, ThreadStatus::Active { active_flags } if active_flags.is_empty())
|
||||
{
|
||||
status = ThreadStatus::Idle;
|
||||
}
|
||||
|
||||
interrupt_stale_in_progress_turns(
|
||||
&mut thread.turns,
|
||||
matches!(status, ThreadStatus::Active { .. }),
|
||||
);
|
||||
thread.status = status;
|
||||
}
|
||||
|
||||
pub(super) fn interrupt_stale_in_progress_turns(turns: &mut [Turn], preserve_latest: bool) {
|
||||
let current_turn_index = preserve_latest
|
||||
.then(|| {
|
||||
turns
|
||||
.iter()
|
||||
.rposition(|turn| matches!(turn.status, TurnStatus::InProgress))
|
||||
})
|
||||
.flatten();
|
||||
for (index, turn) in turns.iter_mut().enumerate() {
|
||||
if matches!(turn.status, TurnStatus::InProgress) && Some(index) != current_turn_index {
|
||||
turn.status = TurnStatus::Interrupted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3990,15 +3990,16 @@ fn reconstruct_thread_turns_for_turns_list(
|
||||
has_live_running_thread: bool,
|
||||
active_turn: Option<Turn>,
|
||||
) -> Vec<Turn> {
|
||||
let has_live_in_progress_turn = has_live_running_thread
|
||||
|| active_turn
|
||||
.as_ref()
|
||||
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress));
|
||||
let mut turns = build_api_turns_from_rollout_items(items);
|
||||
let active_turn_is_current = active_turn.as_ref().is_none_or(|active_turn| {
|
||||
merge_turn_history_with_active_turn(&mut turns, active_turn.clone())
|
||||
});
|
||||
let has_live_in_progress_turn = active_turn_is_current
|
||||
&& (has_live_running_thread
|
||||
|| active_turn
|
||||
.as_ref()
|
||||
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)));
|
||||
normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn);
|
||||
if let Some(active_turn) = active_turn {
|
||||
merge_turn_history_with_active_turn(&mut turns, active_turn);
|
||||
}
|
||||
turns
|
||||
}
|
||||
|
||||
@@ -4008,14 +4009,7 @@ fn normalize_thread_turns_status(
|
||||
has_live_in_progress_turn: bool,
|
||||
) {
|
||||
let status = resolve_thread_status(loaded_status, has_live_in_progress_turn);
|
||||
if matches!(status, ThreadStatus::Active { .. }) {
|
||||
return;
|
||||
}
|
||||
for turn in turns {
|
||||
if matches!(turn.status, TurnStatus::InProgress) {
|
||||
turn.status = TurnStatus::Interrupted;
|
||||
}
|
||||
}
|
||||
interrupt_stale_in_progress_turns(turns, matches!(status, ThreadStatus::Active { .. }));
|
||||
}
|
||||
|
||||
enum ThreadReadViewError {
|
||||
@@ -4489,3 +4483,7 @@ fn build_thread_from_loaded_snapshot(
|
||||
#[cfg(test)]
|
||||
#[path = "thread_processor_tests.rs"]
|
||||
mod thread_processor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "thread_resume_reconciliation_tests.rs"]
|
||||
mod thread_resume_reconciliation_tests;
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
use super::super::thread_lifecycle::merge_turn_history_with_active_turn;
|
||||
use super::super::thread_lifecycle::set_thread_status_and_interrupt_stale_turns;
|
||||
use super::build_thread_resume_initial_turns_page;
|
||||
use codex_app_server_protocol::SessionSource;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadActiveFlag;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams;
|
||||
use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn persisted_terminal_statuses_reject_same_id_in_progress_snapshot() {
|
||||
for persisted_status in [
|
||||
TurnStatus::Completed,
|
||||
TurnStatus::Interrupted,
|
||||
TurnStatus::Failed,
|
||||
] {
|
||||
let older_turn = turn("older", TurnStatus::Completed, "older persisted item");
|
||||
let persisted_turn = turn("current", persisted_status, "authoritative persisted item");
|
||||
let stale_active_turn = turn("current", TurnStatus::InProgress, "stale active item");
|
||||
let mut turns = vec![older_turn.clone(), persisted_turn.clone()];
|
||||
|
||||
let accepted = merge_turn_history_with_active_turn(&mut turns, stale_active_turn);
|
||||
|
||||
assert_eq!(accepted, false);
|
||||
assert_eq!(turns, vec![older_turn, persisted_turn]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_snapshots_replace_or_append_without_duplicates() {
|
||||
struct Case {
|
||||
persisted_turns: Vec<Turn>,
|
||||
active_turn: Turn,
|
||||
expected_turns: Vec<Turn>,
|
||||
}
|
||||
|
||||
let older_turn = turn("older", TurnStatus::Completed, "older");
|
||||
let persisted_partial = turn("current", TurnStatus::InProgress, "persisted partial");
|
||||
let active_partial = turn("current", TurnStatus::InProgress, "active partial");
|
||||
let active_completed = turn("current", TurnStatus::Completed, "active final");
|
||||
let active_interrupted = turn("current", TurnStatus::Interrupted, "active interrupted");
|
||||
let active_failed = turn("current", TurnStatus::Failed, "active failed");
|
||||
let next_active = turn("next", TurnStatus::InProgress, "next active");
|
||||
let cases = [
|
||||
Case {
|
||||
persisted_turns: Vec::new(),
|
||||
active_turn: active_partial.clone(),
|
||||
expected_turns: vec![active_partial.clone()],
|
||||
},
|
||||
Case {
|
||||
persisted_turns: vec![older_turn.clone(), persisted_partial.clone()],
|
||||
active_turn: active_partial.clone(),
|
||||
expected_turns: vec![older_turn.clone(), active_partial],
|
||||
},
|
||||
Case {
|
||||
persisted_turns: vec![older_turn.clone(), persisted_partial.clone()],
|
||||
active_turn: active_completed.clone(),
|
||||
expected_turns: vec![older_turn.clone(), active_completed],
|
||||
},
|
||||
Case {
|
||||
persisted_turns: vec![older_turn.clone(), persisted_partial.clone()],
|
||||
active_turn: active_interrupted.clone(),
|
||||
expected_turns: vec![older_turn.clone(), active_interrupted],
|
||||
},
|
||||
Case {
|
||||
persisted_turns: vec![older_turn.clone(), persisted_partial],
|
||||
active_turn: active_failed.clone(),
|
||||
expected_turns: vec![older_turn.clone(), active_failed],
|
||||
},
|
||||
Case {
|
||||
persisted_turns: vec![older_turn.clone()],
|
||||
active_turn: next_active.clone(),
|
||||
expected_turns: vec![older_turn, next_active],
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let mut turns = case.persisted_turns;
|
||||
|
||||
let accepted = merge_turn_history_with_active_turn(&mut turns, case.active_turn);
|
||||
|
||||
assert_eq!(accepted, true);
|
||||
assert_eq!(turns, case.expected_turns);
|
||||
assert_eq!(
|
||||
turns
|
||||
.iter()
|
||||
.map(|turn| turn.id.as_str())
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
turns.len(),
|
||||
"reconciliation must leave at most one snapshot for each turn id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_status_normalization_matrix() {
|
||||
struct Case {
|
||||
loaded_status: ThreadStatus,
|
||||
has_live_turn: bool,
|
||||
expected_status: ThreadStatus,
|
||||
expected_turn_status: TurnStatus,
|
||||
}
|
||||
|
||||
let active = ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
};
|
||||
let waiting = ThreadStatus::Active {
|
||||
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
|
||||
};
|
||||
let cases = [
|
||||
Case {
|
||||
loaded_status: ThreadStatus::Idle,
|
||||
has_live_turn: false,
|
||||
expected_status: ThreadStatus::Idle,
|
||||
expected_turn_status: TurnStatus::Interrupted,
|
||||
},
|
||||
Case {
|
||||
loaded_status: ThreadStatus::Idle,
|
||||
has_live_turn: true,
|
||||
expected_status: active.clone(),
|
||||
expected_turn_status: TurnStatus::InProgress,
|
||||
},
|
||||
Case {
|
||||
loaded_status: ThreadStatus::NotLoaded,
|
||||
has_live_turn: false,
|
||||
expected_status: ThreadStatus::NotLoaded,
|
||||
expected_turn_status: TurnStatus::Interrupted,
|
||||
},
|
||||
Case {
|
||||
loaded_status: ThreadStatus::NotLoaded,
|
||||
has_live_turn: true,
|
||||
expected_status: active,
|
||||
expected_turn_status: TurnStatus::InProgress,
|
||||
},
|
||||
Case {
|
||||
loaded_status: waiting.clone(),
|
||||
has_live_turn: false,
|
||||
expected_status: waiting.clone(),
|
||||
expected_turn_status: TurnStatus::InProgress,
|
||||
},
|
||||
Case {
|
||||
loaded_status: waiting.clone(),
|
||||
has_live_turn: true,
|
||||
expected_status: waiting,
|
||||
expected_turn_status: TurnStatus::InProgress,
|
||||
},
|
||||
Case {
|
||||
loaded_status: ThreadStatus::SystemError,
|
||||
has_live_turn: false,
|
||||
expected_status: ThreadStatus::SystemError,
|
||||
expected_turn_status: TurnStatus::Interrupted,
|
||||
},
|
||||
Case {
|
||||
loaded_status: ThreadStatus::SystemError,
|
||||
has_live_turn: true,
|
||||
expected_status: ThreadStatus::SystemError,
|
||||
expected_turn_status: TurnStatus::Interrupted,
|
||||
},
|
||||
];
|
||||
|
||||
for case in cases {
|
||||
let original_thread = thread(vec![turn("current", TurnStatus::InProgress, "active item")]);
|
||||
let mut thread = original_thread.clone();
|
||||
|
||||
set_thread_status_and_interrupt_stale_turns(
|
||||
&mut thread,
|
||||
case.loaded_status,
|
||||
case.has_live_turn,
|
||||
);
|
||||
|
||||
let mut expected_thread = original_thread;
|
||||
expected_thread.status = case.expected_status;
|
||||
expected_thread.turns[0].status = case.expected_turn_status;
|
||||
assert_eq!(thread, expected_thread);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_terminal_turn_invalidates_stale_active_thread_status() {
|
||||
let history_items = completed_rollout("current", "persisted user", "persisted answer");
|
||||
let stale_active_turn = turn_with_user_and_agent_items(
|
||||
"current",
|
||||
TurnStatus::InProgress,
|
||||
"stale user",
|
||||
"stale partial",
|
||||
);
|
||||
|
||||
let (resumed_thread, accepted_active_turn, has_live_turn) = project_full_resume(
|
||||
&history_items,
|
||||
Some(stale_active_turn),
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
/*agent_running*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(accepted_active_turn, None);
|
||||
assert_eq!(has_live_turn, false);
|
||||
assert_eq!(resumed_thread.status, ThreadStatus::Idle);
|
||||
assert_eq!(resumed_thread.turns[0].status, TurnStatus::Completed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_persisted_in_progress_turn_is_interrupted_when_new_turn_is_live() {
|
||||
let persisted_turn = turn(
|
||||
"incomplete-old-turn",
|
||||
TurnStatus::InProgress,
|
||||
"persisted partial",
|
||||
);
|
||||
let active_turn = turn("new-live-turn", TurnStatus::InProgress, "live partial");
|
||||
let mut turns = vec![persisted_turn.clone()];
|
||||
assert!(merge_turn_history_with_active_turn(
|
||||
&mut turns,
|
||||
active_turn.clone()
|
||||
));
|
||||
let mut resumed_thread = thread(turns);
|
||||
|
||||
set_thread_status_and_interrupt_stale_turns(
|
||||
&mut resumed_thread,
|
||||
ThreadStatus::Idle,
|
||||
/*has_live_in_progress_turn*/ true,
|
||||
);
|
||||
|
||||
let mut expected_persisted_turn = persisted_turn;
|
||||
expected_persisted_turn.status = TurnStatus::Interrupted;
|
||||
let mut expected_thread = thread(vec![expected_persisted_turn, active_turn]);
|
||||
expected_thread.status = ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
};
|
||||
assert_eq!(resumed_thread, expected_thread);
|
||||
assert_eq!(
|
||||
resumed_thread
|
||||
.turns
|
||||
.iter()
|
||||
.filter(|turn| matches!(turn.status, TurnStatus::InProgress))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_turns_and_initial_page_apply_the_same_system_error_status() {
|
||||
let active_turn = turn("current", TurnStatus::InProgress, "live partial");
|
||||
let mut resumed_thread = thread(vec![active_turn.clone()]);
|
||||
set_thread_status_and_interrupt_stale_turns(
|
||||
&mut resumed_thread,
|
||||
ThreadStatus::SystemError,
|
||||
/*has_live_in_progress_turn*/ true,
|
||||
);
|
||||
|
||||
let initial_page = build_thread_resume_initial_turns_page(
|
||||
&[],
|
||||
resumed_thread.status.clone(),
|
||||
/*has_live_running_thread*/ true,
|
||||
Some(active_turn),
|
||||
&full_ascending_page_params(),
|
||||
)
|
||||
.expect("initial turns page should build");
|
||||
|
||||
assert_eq!(initial_page.data, resumed_thread.turns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_turns_and_initial_page_interrupt_the_same_stale_in_progress_turn() {
|
||||
let history_items = incomplete_rollout("persisted", "persisted user", "persisted partial");
|
||||
let active_turn = turn_with_user_and_agent_items(
|
||||
"active",
|
||||
TurnStatus::InProgress,
|
||||
"active user",
|
||||
"active partial",
|
||||
);
|
||||
let (resumed_thread, accepted_active_turn, has_live_turn) = project_full_resume(
|
||||
&history_items,
|
||||
Some(active_turn),
|
||||
ThreadStatus::Idle,
|
||||
/*agent_running*/ true,
|
||||
);
|
||||
|
||||
let initial_page = build_thread_resume_initial_turns_page(
|
||||
&history_items,
|
||||
resumed_thread.status.clone(),
|
||||
has_live_turn,
|
||||
accepted_active_turn,
|
||||
&full_ascending_page_params(),
|
||||
)
|
||||
.expect("initial turns page should build");
|
||||
|
||||
assert_eq!(initial_page.data, resumed_thread.turns);
|
||||
assert_eq!(
|
||||
initial_page
|
||||
.data
|
||||
.iter()
|
||||
.filter(|turn| matches!(turn.status, TurnStatus::InProgress))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_turns_and_initial_page_share_ids_order_status_and_full_items() {
|
||||
let history_items = completed_rollout("persisted", "persisted user", "persisted answer");
|
||||
let active_turn = turn_with_user_and_agent_items(
|
||||
"active",
|
||||
TurnStatus::InProgress,
|
||||
"active user",
|
||||
"active partial",
|
||||
);
|
||||
let (resumed_thread, accepted_active_turn, has_live_turn) = project_full_resume(
|
||||
&history_items,
|
||||
Some(active_turn),
|
||||
ThreadStatus::Idle,
|
||||
/*agent_running*/ true,
|
||||
);
|
||||
|
||||
let initial_page = build_thread_resume_initial_turns_page(
|
||||
&history_items,
|
||||
resumed_thread.status.clone(),
|
||||
has_live_turn,
|
||||
accepted_active_turn,
|
||||
&full_ascending_page_params(),
|
||||
)
|
||||
.expect("initial turns page should build");
|
||||
|
||||
assert_eq!(initial_page.data, resumed_thread.turns);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_page_item_views_do_not_change_turn_identity_or_status() {
|
||||
let active_turn = turn_with_user_and_agent_items(
|
||||
"active",
|
||||
TurnStatus::InProgress,
|
||||
"active user",
|
||||
"active partial",
|
||||
);
|
||||
|
||||
for items_view in [
|
||||
TurnItemsView::NotLoaded,
|
||||
TurnItemsView::Summary,
|
||||
TurnItemsView::Full,
|
||||
] {
|
||||
let page = build_thread_resume_initial_turns_page(
|
||||
&[],
|
||||
ThreadStatus::Active {
|
||||
active_flags: Vec::new(),
|
||||
},
|
||||
/*has_live_running_thread*/ true,
|
||||
Some(active_turn.clone()),
|
||||
&ThreadResumeInitialTurnsPageParams {
|
||||
limit: None,
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
items_view: Some(items_view),
|
||||
},
|
||||
)
|
||||
.expect("initial turns page should build");
|
||||
|
||||
let mut expected_turn = active_turn.clone();
|
||||
match items_view {
|
||||
TurnItemsView::NotLoaded => expected_turn.items.clear(),
|
||||
TurnItemsView::Summary | TurnItemsView::Full => {}
|
||||
}
|
||||
expected_turn.items_view = items_view;
|
||||
assert_eq!(page.data, vec![expected_turn]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_active_snapshot_is_excluded_from_both_full_turns_and_initial_page() {
|
||||
let history_items = completed_rollout("current", "persisted user", "persisted answer");
|
||||
let stale_active_turn = turn_with_user_and_agent_items(
|
||||
"current",
|
||||
TurnStatus::InProgress,
|
||||
"stale user",
|
||||
"stale partial",
|
||||
);
|
||||
let (resumed_thread, accepted_active_turn, has_live_turn) = project_full_resume(
|
||||
&history_items,
|
||||
Some(stale_active_turn),
|
||||
ThreadStatus::Idle,
|
||||
/*agent_running*/ true,
|
||||
);
|
||||
assert_eq!(accepted_active_turn, None);
|
||||
assert_eq!(has_live_turn, false);
|
||||
|
||||
let initial_page = build_thread_resume_initial_turns_page(
|
||||
&history_items,
|
||||
resumed_thread.status.clone(),
|
||||
has_live_turn,
|
||||
accepted_active_turn,
|
||||
&full_ascending_page_params(),
|
||||
)
|
||||
.expect("initial turns page should build");
|
||||
|
||||
assert_eq!(initial_page.data, resumed_thread.turns);
|
||||
assert_eq!(resumed_thread.status, ThreadStatus::Idle);
|
||||
assert_eq!(resumed_thread.turns[0].status, TurnStatus::Completed);
|
||||
}
|
||||
|
||||
fn project_full_resume(
|
||||
history_items: &[RolloutItem],
|
||||
active_turn: Option<Turn>,
|
||||
loaded_status: ThreadStatus,
|
||||
agent_running: bool,
|
||||
) -> (Thread, Option<Turn>, bool) {
|
||||
let mut turns = codex_app_server_protocol::build_turns_from_rollout_items(history_items);
|
||||
let active_turn_is_current = active_turn.as_ref().is_none_or(|active_turn| {
|
||||
merge_turn_history_with_active_turn(&mut turns, active_turn.clone())
|
||||
});
|
||||
let active_turn = active_turn.filter(|_| active_turn_is_current);
|
||||
let has_live_turn = active_turn_is_current
|
||||
&& (agent_running
|
||||
|| active_turn
|
||||
.as_ref()
|
||||
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)));
|
||||
let mut thread = thread(turns);
|
||||
set_thread_status_and_interrupt_stale_turns(&mut thread, loaded_status, has_live_turn);
|
||||
(thread, active_turn, has_live_turn)
|
||||
}
|
||||
|
||||
fn full_ascending_page_params() -> ThreadResumeInitialTurnsPageParams {
|
||||
ThreadResumeInitialTurnsPageParams {
|
||||
limit: None,
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
items_view: Some(TurnItemsView::Full),
|
||||
}
|
||||
}
|
||||
|
||||
fn turn(id: &str, status: TurnStatus, item_text: &str) -> Turn {
|
||||
Turn {
|
||||
id: id.to_string(),
|
||||
items: vec![ThreadItem::AgentMessage {
|
||||
id: format!("{id}-item"),
|
||||
text: item_text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}],
|
||||
items_view: TurnItemsView::Full,
|
||||
error: None,
|
||||
started_at: Some(1),
|
||||
completed_at: (!matches!(status, TurnStatus::InProgress)).then_some(2),
|
||||
duration_ms: (!matches!(status, TurnStatus::InProgress)).then_some(1_000),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn turn_with_user_and_agent_items(
|
||||
id: &str,
|
||||
status: TurnStatus,
|
||||
user_text: &str,
|
||||
agent_text: &str,
|
||||
) -> Turn {
|
||||
Turn {
|
||||
id: id.to_string(),
|
||||
items: vec![
|
||||
ThreadItem::UserMessage {
|
||||
id: format!("{id}-user"),
|
||||
client_id: None,
|
||||
content: vec![UserInput::Text {
|
||||
text: user_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
},
|
||||
ThreadItem::AgentMessage {
|
||||
id: format!("{id}-agent"),
|
||||
text: agent_text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
},
|
||||
],
|
||||
items_view: TurnItemsView::Full,
|
||||
error: None,
|
||||
started_at: Some(1),
|
||||
completed_at: (!matches!(status, TurnStatus::InProgress)).then_some(2),
|
||||
duration_ms: (!matches!(status, TurnStatus::InProgress)).then_some(1_000),
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn completed_rollout(turn_id: &str, user_text: &str, agent_text: &str) -> Vec<RolloutItem> {
|
||||
[
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: Some(1),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
client_id: None,
|
||||
message: user_text.to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: agent_text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
last_agent_message: Some(agent_text.to_string()),
|
||||
completed_at: Some(2),
|
||||
duration_ms: Some(1_000),
|
||||
time_to_first_token_ms: None,
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(RolloutItem::EventMsg)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn incomplete_rollout(turn_id: &str, user_text: &str, agent_text: &str) -> Vec<RolloutItem> {
|
||||
[
|
||||
EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: Some(1),
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}),
|
||||
EventMsg::UserMessage(UserMessageEvent {
|
||||
client_id: None,
|
||||
message: user_text.to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
..Default::default()
|
||||
}),
|
||||
EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: agent_text.to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
}),
|
||||
]
|
||||
.into_iter()
|
||||
.map(RolloutItem::EventMsg)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn thread(turns: Vec<Turn>) -> Thread {
|
||||
Thread {
|
||||
id: "thread".to_string(),
|
||||
extra: None,
|
||||
session_id: "session".to_string(),
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
preview: String::new(),
|
||||
ephemeral: false,
|
||||
model_provider: "test".to_string(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
recency_at: None,
|
||||
status: ThreadStatus::Idle,
|
||||
path: None,
|
||||
cwd: AbsolutePathBuf::from_absolute_path(std::env::temp_dir())
|
||||
.expect("temporary directory should be absolute"),
|
||||
cli_version: "test".to_string(),
|
||||
source: SessionSource::Cli,
|
||||
thread_source: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
git_info: None,
|
||||
name: None,
|
||||
turns,
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,13 @@ use codex_app_server_protocol::ThreadTurnsListParams;
|
||||
use codex_app_server_protocol::ThreadTurnsListResponse;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
use codex_core::RolloutRecorder;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
@@ -392,7 +397,7 @@ async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_tim
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Result<()> {
|
||||
async fn websocket_reconnect_recovers_in_progress_and_terminal_turn_state() -> Result<()> {
|
||||
let (emit_deltas_tx, emit_deltas_rx) = oneshot::channel();
|
||||
let (complete_turn_tx, complete_turn_rx) = oneshot::channel();
|
||||
let (server, _) = start_streaming_sse_server(vec![vec![
|
||||
@@ -426,7 +431,22 @@ async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Resul
|
||||
let mut ws1 = connect_websocket(bind_addr).await?;
|
||||
send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?;
|
||||
read_response_for_id(&mut ws1, /*id*/ 1).await?;
|
||||
let thread_id = start_thread(&mut ws1, /*id*/ 2).await?;
|
||||
send_request(
|
||||
&mut ws1,
|
||||
"thread/start",
|
||||
/*id*/ 2,
|
||||
Some(serde_json::to_value(ThreadStartParams {
|
||||
model: Some("mock-model".to_string()),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let response = read_response_for_id(&mut ws1, /*id*/ 2).await?;
|
||||
let ThreadStartResponse { thread, .. } = to_response(response)?;
|
||||
let thread_id = thread.id;
|
||||
let rollout_path = thread
|
||||
.path
|
||||
.context("started thread should have a rollout path")?;
|
||||
|
||||
send_request(
|
||||
&mut ws1,
|
||||
@@ -442,26 +462,21 @@ async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Resul
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
read_response_for_id(&mut ws1, /*id*/ 3).await?;
|
||||
let response = read_response_for_id(&mut ws1, /*id*/ 3).await?;
|
||||
let TurnStartResponse { turn } = to_response(response)?;
|
||||
let turn_id = turn.id;
|
||||
read_notification_for_method(&mut ws1, "item/started").await?;
|
||||
|
||||
send_request(
|
||||
&mut ws1,
|
||||
"thread/unsubscribe",
|
||||
/*id*/ 4,
|
||||
Some(json!({ "threadId": thread_id })),
|
||||
)
|
||||
.await?;
|
||||
read_response_for_id(&mut ws1, /*id*/ 4).await?;
|
||||
|
||||
ws1.close(None).await.context("failed to close websocket")?;
|
||||
drop(ws1);
|
||||
emit_deltas_tx
|
||||
.send(())
|
||||
.expect("streaming response should still be waiting to emit deltas");
|
||||
|
||||
let mut ws2 = connect_websocket(bind_addr).await?;
|
||||
send_request(
|
||||
&mut ws2,
|
||||
"initialize",
|
||||
/*id*/ 5,
|
||||
/*id*/ 4,
|
||||
Some(serde_json::to_value(InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex_chatgpt_ios_remote".to_string(),
|
||||
@@ -475,13 +490,9 @@ async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Resul
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
read_response_for_id(&mut ws2, /*id*/ 5).await?;
|
||||
read_response_for_id(&mut ws2, /*id*/ 4).await?;
|
||||
|
||||
emit_deltas_tx
|
||||
.send(())
|
||||
.expect("streaming response should still be waiting to emit deltas");
|
||||
|
||||
let mut request_id = 6;
|
||||
let mut request_id = 5;
|
||||
loop {
|
||||
send_request(
|
||||
&mut ws2,
|
||||
@@ -500,14 +511,15 @@ async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Resul
|
||||
let ThreadTurnsListResponse { data, .. } = to_response(response)?;
|
||||
request_id += 1;
|
||||
if data.iter().any(|turn| {
|
||||
turn.status == TurnStatus::InProgress
|
||||
turn.id == turn_id
|
||||
&& turn.status == TurnStatus::InProgress
|
||||
&& turn.items.iter().any(|item| {
|
||||
matches!(item, ThreadItem::AgentMessage { id, text, .. } if id == "msg-1" && text == "hello world")
|
||||
matches!(item, ThreadItem::AgentMessage { text, .. } if text == "hello world")
|
||||
})
|
||||
}) {
|
||||
break;
|
||||
}
|
||||
if request_id == 26 {
|
||||
if request_id == 25 {
|
||||
bail!("thread never reflected deltas emitted with no subscribed client");
|
||||
}
|
||||
}
|
||||
@@ -536,17 +548,102 @@ async fn websocket_reconnect_resumes_in_progress_agent_message_deltas() -> Resul
|
||||
.expect("resume should include the requested initial turns page")
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|turn| turn.status == TurnStatus::InProgress)
|
||||
.expect("resume should include the in-progress turn");
|
||||
assert!(active_turn.items.iter().any(|item| {
|
||||
matches!(item, ThreadItem::AgentMessage { id, text, .. } if id == "msg-1" && text == "hello world")
|
||||
}));
|
||||
.find(|turn| turn.id == turn_id && turn.status == TurnStatus::InProgress)
|
||||
.expect("resume should include the in-progress turn updated while disconnected");
|
||||
assert_eq!(
|
||||
active_turn
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ThreadItem::AgentMessage { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["hello world"]
|
||||
);
|
||||
|
||||
drop(ws2);
|
||||
complete_turn_tx
|
||||
.send(())
|
||||
.expect("streaming response should still be waiting for completion");
|
||||
read_notification_for_method(&mut ws2, "item/completed").await?;
|
||||
read_notification_for_method(&mut ws2, "turn/completed").await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, async {
|
||||
loop {
|
||||
let InitialHistory::Resumed(history) =
|
||||
RolloutRecorder::get_rollout_history(&rollout_path).await?
|
||||
else {
|
||||
bail!("started thread should have resumable rollout history");
|
||||
};
|
||||
if history.history.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(event))
|
||||
if event.turn_id == turn_id
|
||||
)
|
||||
}) {
|
||||
return Ok::<(), anyhow::Error>(());
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("turn did not finish persisting while the websocket was disconnected")??;
|
||||
|
||||
let mut ws3 = connect_websocket(bind_addr).await?;
|
||||
send_request(
|
||||
&mut ws3,
|
||||
"initialize",
|
||||
/*id*/ 6,
|
||||
Some(serde_json::to_value(InitializeParams {
|
||||
client_info: ClientInfo {
|
||||
name: "codex_chatgpt_ios_remote".to_string(),
|
||||
title: Some("WebSocket Test Client".to_string()),
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
read_response_for_id(&mut ws3, /*id*/ 6).await?;
|
||||
send_request(
|
||||
&mut ws3,
|
||||
"thread/resume",
|
||||
/*id*/ 7,
|
||||
Some(serde_json::to_value(ThreadResumeParams {
|
||||
thread_id: thread_id.clone(),
|
||||
exclude_turns: true,
|
||||
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
|
||||
limit: Some(5),
|
||||
sort_direction: Some(SortDirection::Desc),
|
||||
items_view: Some(TurnItemsView::Full),
|
||||
}),
|
||||
..Default::default()
|
||||
})?),
|
||||
)
|
||||
.await?;
|
||||
let response = read_response_for_id(&mut ws3, /*id*/ 7).await?;
|
||||
let ThreadResumeResponse {
|
||||
initial_turns_page, ..
|
||||
} = to_response(response)?;
|
||||
let completed_turn = initial_turns_page
|
||||
.expect("resume should include the requested initial turns page")
|
||||
.data
|
||||
.into_iter()
|
||||
.find(|turn| turn.id == turn_id && turn.status == TurnStatus::Completed)
|
||||
.expect("resume should include the turn completed while disconnected");
|
||||
assert_eq!(
|
||||
completed_turn
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ThreadItem::AgentMessage { text, .. } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["hello world"]
|
||||
);
|
||||
|
||||
process
|
||||
.kill()
|
||||
|
||||
@@ -26,18 +26,22 @@ use codex_app_server::in_process::InProcessServerEvent;
|
||||
use codex_app_server::in_process::InProcessStartArgs;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::ClientRequest;
|
||||
use codex_app_server_protocol::InitializeCapabilities;
|
||||
use codex_app_server_protocol::InitializeParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::SortDirection;
|
||||
use codex_app_server_protocol::ThreadDeleteParams;
|
||||
use codex_app_server_protocol::ThreadDeleteResponse;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadListParams;
|
||||
use codex_app_server_protocol::ThreadListResponse;
|
||||
use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams;
|
||||
use codex_app_server_protocol::ThreadResumeParams;
|
||||
use codex_app_server_protocol::ThreadResumeResponse;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
@@ -405,6 +409,117 @@ async fn running_thread_resume_includes_completion_after_initial_history_snapsho
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn running_thread_resume_reads_history_for_every_response_shape_that_needs_it() -> Result<()>
|
||||
{
|
||||
struct Case {
|
||||
exclude_turns: bool,
|
||||
include_initial_page: bool,
|
||||
expected_history_reads: usize,
|
||||
}
|
||||
|
||||
let server = create_mock_responses_server_repeating_assistant("Done").await;
|
||||
let codex_home = TempDir::new()?;
|
||||
let store_id = Uuid::new_v4().to_string();
|
||||
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
|
||||
|
||||
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
|
||||
let _in_memory_store = InMemoryThreadStoreId { store_id };
|
||||
let mut client = start_in_process_server(codex_home.path()).await?;
|
||||
let response = client
|
||||
.request(ClientRequest::ThreadStart {
|
||||
request_id: RequestId::Integer(1),
|
||||
params: ThreadStartParams::default(),
|
||||
})
|
||||
.await?
|
||||
.expect("thread/start should succeed");
|
||||
let ThreadStartResponse { thread, .. } = serde_json::from_value(response)?;
|
||||
client
|
||||
.request(ClientRequest::TurnStart {
|
||||
request_id: RequestId::Integer(2),
|
||||
params: TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Materialize one completed turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("turn/start should succeed");
|
||||
wait_for_thread_notification(&mut client, &thread.id, |notification| {
|
||||
matches!(notification, ServerNotification::TurnCompleted(_))
|
||||
})
|
||||
.await?;
|
||||
|
||||
let cases = [
|
||||
Case {
|
||||
exclude_turns: true,
|
||||
include_initial_page: false,
|
||||
expected_history_reads: 0,
|
||||
},
|
||||
Case {
|
||||
exclude_turns: false,
|
||||
include_initial_page: false,
|
||||
expected_history_reads: 1,
|
||||
},
|
||||
Case {
|
||||
exclude_turns: true,
|
||||
include_initial_page: true,
|
||||
expected_history_reads: 1,
|
||||
},
|
||||
Case {
|
||||
exclude_turns: false,
|
||||
include_initial_page: true,
|
||||
expected_history_reads: 1,
|
||||
},
|
||||
];
|
||||
|
||||
for (index, case) in cases.into_iter().enumerate() {
|
||||
let reads_before = thread_store.calls().await.load_history;
|
||||
let response = client
|
||||
.request(ClientRequest::ThreadResume {
|
||||
request_id: RequestId::Integer(3 + index as i64),
|
||||
params: ThreadResumeParams {
|
||||
thread_id: thread.id.clone(),
|
||||
exclude_turns: case.exclude_turns,
|
||||
initial_turns_page: case.include_initial_page.then_some(
|
||||
ThreadResumeInitialTurnsPageParams {
|
||||
limit: None,
|
||||
sort_direction: Some(SortDirection::Asc),
|
||||
items_view: Some(TurnItemsView::Full),
|
||||
},
|
||||
),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
.await?
|
||||
.expect("thread/resume should succeed");
|
||||
let response: ThreadResumeResponse = serde_json::from_value(response)?;
|
||||
|
||||
assert_eq!(
|
||||
thread_store.calls().await.load_history,
|
||||
reads_before + case.expected_history_reads
|
||||
);
|
||||
assert_eq!(response.thread.turns.is_empty(), case.exclude_turns);
|
||||
assert_eq!(
|
||||
response.initial_turns_page.is_some(),
|
||||
case.include_initial_page
|
||||
);
|
||||
if let Some(page) = response.initial_turns_page {
|
||||
assert!(!page.data.is_empty());
|
||||
if !case.exclude_turns {
|
||||
assert_eq!(page.data, response.thread.turns);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
client.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_thread_notification(
|
||||
client: &mut InProcessClientHandle,
|
||||
thread_id: &str,
|
||||
@@ -470,7 +585,10 @@ async fn start_in_process_client(
|
||||
title: None,
|
||||
version: "0.1.0".to_string(),
|
||||
},
|
||||
capabilities: None,
|
||||
capabilities: Some(InitializeCapabilities {
|
||||
experimental_api: true,
|
||||
..Default::default()
|
||||
}),
|
||||
},
|
||||
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user