app-server: improve thread resume rejoin flow (#11776)

thread/resume response includes latest turn with all items, in band so
no events are stale or lost

Testing
- e2e tested using app-server-test-client using flow described in
"Testing Thread Rejoin Behavior" in
codex-rs/app-server-test-client/README.md
- e2e tested in codex desktop by reconnecting to a running turn
This commit is contained in:
Max Johnson
2026-02-20 05:29:05 +00:00
committed by GitHub
parent 366ecaf17a
commit b06f91c4fe
9 changed files with 733 additions and 196 deletions
+1
View File
@@ -1354,6 +1354,7 @@ dependencies = [
"strum_macros 0.27.2", "strum_macros 0.27.2",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tracing",
"ts-rs", "ts-rs",
"uuid", "uuid",
] ]
+1
View File
@@ -25,6 +25,7 @@ strum_macros = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ts-rs = { workspace = true } ts-rs = { workspace = true }
inventory = { workspace = true } inventory = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["serde", "v7"] } uuid = { workspace = true, features = ["serde", "v7"] }
[dev-dependencies] [dev-dependencies]
@@ -24,9 +24,13 @@ use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ContextCompactedEvent; use codex_protocol::protocol::ContextCompactedEvent;
use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandBeginEvent;
use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::PatchApplyBeginEvent;
use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::PatchApplyEndEvent;
use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutItem;
@@ -36,8 +40,10 @@ use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::UserMessageEvent;
use codex_protocol::protocol::ViewImageToolCallEvent; use codex_protocol::protocol::ViewImageToolCallEvent;
use codex_protocol::protocol::WebSearchBeginEvent;
use codex_protocol::protocol::WebSearchEndEvent; use codex_protocol::protocol::WebSearchEndEvent;
use std::collections::HashMap; use std::collections::HashMap;
use tracing::warn;
use uuid::Uuid; use uuid::Uuid;
#[cfg(test)] #[cfg(test)]
@@ -57,14 +63,20 @@ pub fn build_turns_from_rollout_items(items: &[RolloutItem]) -> Vec<Turn> {
builder.finish() builder.finish()
} }
struct ThreadHistoryBuilder { pub struct ThreadHistoryBuilder {
turns: Vec<Turn>, turns: Vec<Turn>,
current_turn: Option<PendingTurn>, current_turn: Option<PendingTurn>,
next_item_index: i64, next_item_index: i64,
} }
impl Default for ThreadHistoryBuilder {
fn default() -> Self {
Self::new()
}
}
impl ThreadHistoryBuilder { impl ThreadHistoryBuilder {
fn new() -> Self { pub fn new() -> Self {
Self { Self {
turns: Vec::new(), turns: Vec::new(),
current_turn: None, current_turn: None,
@@ -72,14 +84,32 @@ impl ThreadHistoryBuilder {
} }
} }
fn finish(mut self) -> Vec<Turn> { pub fn reset(&mut self) {
*self = Self::new();
}
pub fn finish(mut self) -> Vec<Turn> {
self.finish_current_turn(); self.finish_current_turn();
self.turns self.turns
} }
pub fn active_turn_snapshot(&self) -> Option<Turn> {
self.current_turn
.as_ref()
.map(Turn::from)
.or_else(|| self.turns.last().cloned())
}
pub fn has_active_turn(&self) -> bool {
self.current_turn.is_some()
}
/// Shared reducer for persisted rollout replay and in-memory current-turn
/// tracking used by running thread resume/rejoin.
///
/// This function should handle all EventMsg variants that can be persisted in a rollout file. /// This function should handle all EventMsg variants that can be persisted in a rollout file.
/// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`. /// See `should_persist_event_msg` in `codex-rs/core/rollout/policy.rs`.
fn handle_event(&mut self, event: &EventMsg) { pub fn handle_event(&mut self, event: &EventMsg) {
match event { match event {
EventMsg::UserMessage(payload) => self.handle_user_message(payload), EventMsg::UserMessage(payload) => self.handle_user_message(payload),
EventMsg::AgentMessage(payload) => { EventMsg::AgentMessage(payload) => {
@@ -89,21 +119,35 @@ impl ThreadHistoryBuilder {
EventMsg::AgentReasoningRawContent(payload) => { EventMsg::AgentReasoningRawContent(payload) => {
self.handle_agent_reasoning_raw_content(payload) self.handle_agent_reasoning_raw_content(payload)
} }
EventMsg::WebSearchBegin(payload) => self.handle_web_search_begin(payload),
EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload), EventMsg::WebSearchEnd(payload) => self.handle_web_search_end(payload),
EventMsg::ExecCommandBegin(payload) => self.handle_exec_command_begin(payload),
EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload), EventMsg::ExecCommandEnd(payload) => self.handle_exec_command_end(payload),
EventMsg::PatchApplyBegin(payload) => self.handle_patch_apply_begin(payload),
EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload), EventMsg::PatchApplyEnd(payload) => self.handle_patch_apply_end(payload),
EventMsg::McpToolCallBegin(payload) => self.handle_mcp_tool_call_begin(payload),
EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload), EventMsg::McpToolCallEnd(payload) => self.handle_mcp_tool_call_end(payload),
EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload), EventMsg::ViewImageToolCall(payload) => self.handle_view_image_tool_call(payload),
EventMsg::CollabAgentSpawnBegin(payload) => {
self.handle_collab_agent_spawn_begin(payload)
}
EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload), EventMsg::CollabAgentSpawnEnd(payload) => self.handle_collab_agent_spawn_end(payload),
EventMsg::CollabAgentInteractionBegin(payload) => {
self.handle_collab_agent_interaction_begin(payload)
}
EventMsg::CollabAgentInteractionEnd(payload) => { EventMsg::CollabAgentInteractionEnd(payload) => {
self.handle_collab_agent_interaction_end(payload) self.handle_collab_agent_interaction_end(payload)
} }
EventMsg::CollabWaitingBegin(payload) => self.handle_collab_waiting_begin(payload),
EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload), EventMsg::CollabWaitingEnd(payload) => self.handle_collab_waiting_end(payload),
EventMsg::CollabCloseBegin(payload) => self.handle_collab_close_begin(payload),
EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload), EventMsg::CollabCloseEnd(payload) => self.handle_collab_close_end(payload),
EventMsg::CollabResumeBegin(payload) => self.handle_collab_resume_begin(payload),
EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload), EventMsg::CollabResumeEnd(payload) => self.handle_collab_resume_end(payload),
EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload), EventMsg::ContextCompacted(payload) => self.handle_context_compacted(payload),
EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload), EventMsg::EnteredReviewMode(payload) => self.handle_entered_review_mode(payload),
EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload), EventMsg::ExitedReviewMode(payload) => self.handle_exited_review_mode(payload),
EventMsg::ItemStarted(payload) => self.handle_item_started(payload),
EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload),
EventMsg::Error(payload) => self.handle_error(payload), EventMsg::Error(payload) => self.handle_error(payload),
EventMsg::TokenCount(_) => {} EventMsg::TokenCount(_) => {}
@@ -116,7 +160,7 @@ impl ThreadHistoryBuilder {
} }
} }
fn handle_rollout_item(&mut self, item: &RolloutItem) { pub fn handle_rollout_item(&mut self, item: &RolloutItem) {
match item { match item {
RolloutItem::EventMsg(event) => self.handle_event(event), RolloutItem::EventMsg(event) => self.handle_event(event),
RolloutItem::Compacted(payload) => self.handle_compacted(payload), RolloutItem::Compacted(payload) => self.handle_compacted(payload),
@@ -199,15 +243,51 @@ impl ThreadHistoryBuilder {
}); });
} }
fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) { fn handle_item_started(&mut self, payload: &ItemStartedEvent) {
if let codex_protocol::items::TurnItem::Plan(plan) = &payload.item match &payload.item {
&& plan.text.is_empty() codex_protocol::items::TurnItem::Plan(plan) => {
{ if plan.text.is_empty() {
return; return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
} }
}
let item = ThreadItem::from(payload.item.clone()); fn handle_item_completed(&mut self, payload: &ItemCompletedEvent) {
self.ensure_turn().items.push(item); match &payload.item {
codex_protocol::items::TurnItem::Plan(plan) => {
if plan.text.is_empty() {
return;
}
self.upsert_item_in_turn_id(
&payload.turn_id,
ThreadItem::from(payload.item.clone()),
);
}
codex_protocol::items::TurnItem::UserMessage(_)
| codex_protocol::items::TurnItem::AgentMessage(_)
| codex_protocol::items::TurnItem::Reasoning(_)
| codex_protocol::items::TurnItem::WebSearch(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
fn handle_web_search_begin(&mut self, payload: &WebSearchBeginEvent) {
let item = ThreadItem::WebSearch {
id: payload.call_id.clone(),
query: String::new(),
action: None,
};
self.upsert_item_in_current_turn(item);
} }
fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) { fn handle_web_search_end(&mut self, payload: &WebSearchEndEvent) {
@@ -216,7 +296,30 @@ impl ThreadHistoryBuilder {
query: payload.query.clone(), query: payload.query.clone(),
action: Some(WebSearchAction::from(payload.action.clone())), action: Some(WebSearchAction::from(payload.action.clone())),
}; };
self.ensure_turn().items.push(item); self.upsert_item_in_current_turn(item);
}
fn handle_exec_command_begin(&mut self, payload: &ExecCommandBeginEvent) {
let command = shlex::try_join(payload.command.iter().map(String::as_str))
.unwrap_or_else(|_| payload.command.join(" "));
let command_actions = payload
.parsed_cmd
.iter()
.cloned()
.map(CommandAction::from)
.collect();
let item = ThreadItem::CommandExecution {
id: payload.call_id.clone(),
command,
cwd: payload.cwd.clone(),
process_id: payload.process_id.clone(),
status: CommandExecutionStatus::InProgress,
command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
};
self.upsert_item_in_turn_id(&payload.turn_id, item);
} }
fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) { fn handle_exec_command_end(&mut self, payload: &ExecCommandEndEvent) {
@@ -246,33 +349,25 @@ impl ThreadHistoryBuilder {
exit_code: Some(payload.exit_code), exit_code: Some(payload.exit_code),
duration_ms: Some(duration_ms), duration_ms: Some(duration_ms),
}; };
// Command completions can arrive out of order. Unified exec may return // Command completions can arrive out of order. Unified exec may return
// while a PTY is still running, then emit ExecCommandEnd later from a // while a PTY is still running, then emit ExecCommandEnd later from a
// background exit watcher when that process finally exits. By then, a // background exit watcher when that process finally exits. By then, a
// newer user turn may already have started. Route by event turn_id so // newer user turn may already have started. Route by event turn_id so
// replay preserves the original turn association. // replay preserves the original turn association.
if let Some(turn) = self.current_turn.as_mut() self.upsert_item_in_turn_id(&payload.turn_id, item);
&& turn.id == payload.turn_id }
{
turn.items.push(item);
return;
}
// If the originating turn is already finalized, append there instead fn handle_patch_apply_begin(&mut self, payload: &PatchApplyBeginEvent) {
// of attaching to whichever turn is currently active during replay. let item = ThreadItem::FileChange {
if let Some(turn) = self id: payload.call_id.clone(),
.turns changes: convert_patch_changes(&payload.changes),
.iter_mut() status: PatchApplyStatus::InProgress,
.find(|turn| turn.id == payload.turn_id) };
{ if payload.turn_id.is_empty() {
turn.items.push(item); self.upsert_item_in_current_turn(item);
return; } else {
self.upsert_item_in_turn_id(&payload.turn_id, item);
} }
// Backward-compatibility fallback for partial/legacy streams where the
// event turn_id does not match any known replay turn.
self.ensure_turn().items.push(item);
} }
fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) { fn handle_patch_apply_end(&mut self, payload: &PatchApplyEndEvent) {
@@ -282,7 +377,29 @@ impl ThreadHistoryBuilder {
changes: convert_patch_changes(&payload.changes), changes: convert_patch_changes(&payload.changes),
status, status,
}; };
self.ensure_turn().items.push(item); if payload.turn_id.is_empty() {
self.upsert_item_in_current_turn(item);
} else {
self.upsert_item_in_turn_id(&payload.turn_id, item);
}
}
fn handle_mcp_tool_call_begin(&mut self, payload: &McpToolCallBeginEvent) {
let item = ThreadItem::McpToolCall {
id: payload.call_id.clone(),
server: payload.invocation.server.clone(),
tool: payload.invocation.tool.clone(),
status: McpToolCallStatus::InProgress,
arguments: payload
.invocation
.arguments
.clone()
.unwrap_or(serde_json::Value::Null),
result: None,
error: None,
duration_ms: None,
};
self.upsert_item_in_current_turn(item);
} }
fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) { fn handle_mcp_tool_call_end(&mut self, payload: &McpToolCallEndEvent) {
@@ -321,7 +438,7 @@ impl ThreadHistoryBuilder {
error, error,
duration_ms, duration_ms,
}; };
self.ensure_turn().items.push(item); self.upsert_item_in_current_turn(item);
} }
fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) { fn handle_view_image_tool_call(&mut self, payload: &ViewImageToolCallEvent) {
@@ -329,7 +446,23 @@ impl ThreadHistoryBuilder {
id: payload.call_id.clone(), id: payload.call_id.clone(),
path: payload.path.to_string_lossy().into_owned(), path: payload.path.to_string_lossy().into_owned(),
}; };
self.ensure_turn().items.push(item); self.upsert_item_in_current_turn(item);
}
fn handle_collab_agent_spawn_begin(
&mut self,
payload: &codex_protocol::protocol::CollabAgentSpawnBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: Vec::new(),
prompt: Some(payload.prompt.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
} }
fn handle_collab_agent_spawn_end( fn handle_collab_agent_spawn_end(
@@ -353,17 +486,31 @@ impl ThreadHistoryBuilder {
} }
None => (Vec::new(), HashMap::new()), None => (Vec::new(), HashMap::new()),
}; };
self.ensure_turn() self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
.items id: payload.call_id.clone(),
.push(ThreadItem::CollabAgentToolCall { tool: CollabAgentTool::SpawnAgent,
id: payload.call_id.clone(), status,
tool: CollabAgentTool::SpawnAgent, sender_thread_id: payload.sender_thread_id.to_string(),
status, receiver_thread_ids,
sender_thread_id: payload.sender_thread_id.to_string(), prompt: Some(payload.prompt.clone()),
receiver_thread_ids, agents_states,
prompt: Some(payload.prompt.clone()), });
agents_states, }
});
fn handle_collab_agent_interaction_begin(
&mut self,
payload: &codex_protocol::protocol::CollabAgentInteractionBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: Some(payload.prompt.clone()),
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
} }
fn handle_collab_agent_interaction_end( fn handle_collab_agent_interaction_end(
@@ -376,17 +523,35 @@ impl ThreadHistoryBuilder {
}; };
let receiver_id = payload.receiver_thread_id.to_string(); let receiver_id = payload.receiver_thread_id.to_string();
let received_status = CollabAgentState::from(payload.status.clone()); let received_status = CollabAgentState::from(payload.status.clone());
self.ensure_turn() self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
.items id: payload.call_id.clone(),
.push(ThreadItem::CollabAgentToolCall { tool: CollabAgentTool::SendInput,
id: payload.call_id.clone(), status,
tool: CollabAgentTool::SendInput, sender_thread_id: payload.sender_thread_id.to_string(),
status, receiver_thread_ids: vec![receiver_id.clone()],
sender_thread_id: payload.sender_thread_id.to_string(), prompt: Some(payload.prompt.clone()),
receiver_thread_ids: vec![receiver_id.clone()], agents_states: [(receiver_id, received_status)].into_iter().collect(),
prompt: Some(payload.prompt.clone()), });
agents_states: [(receiver_id, received_status)].into_iter().collect(), }
});
fn handle_collab_waiting_begin(
&mut self,
payload: &codex_protocol::protocol::CollabWaitingBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::Wait,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: payload
.receiver_thread_ids
.iter()
.map(ToString::to_string)
.collect(),
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
} }
fn handle_collab_waiting_end( fn handle_collab_waiting_end(
@@ -410,17 +575,31 @@ impl ThreadHistoryBuilder {
.iter() .iter()
.map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone()))) .map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone())))
.collect(); .collect();
self.ensure_turn() self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
.items id: payload.call_id.clone(),
.push(ThreadItem::CollabAgentToolCall { tool: CollabAgentTool::Wait,
id: payload.call_id.clone(), status,
tool: CollabAgentTool::Wait, sender_thread_id: payload.sender_thread_id.to_string(),
status, receiver_thread_ids,
sender_thread_id: payload.sender_thread_id.to_string(), prompt: None,
receiver_thread_ids, agents_states,
prompt: None, });
agents_states, }
});
fn handle_collab_close_begin(
&mut self,
payload: &codex_protocol::protocol::CollabCloseBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::CloseAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
} }
fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) { fn handle_collab_close_end(&mut self, payload: &codex_protocol::protocol::CollabCloseEndEvent) {
@@ -435,17 +614,31 @@ impl ThreadHistoryBuilder {
)] )]
.into_iter() .into_iter()
.collect(); .collect();
self.ensure_turn() self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
.items id: payload.call_id.clone(),
.push(ThreadItem::CollabAgentToolCall { tool: CollabAgentTool::CloseAgent,
id: payload.call_id.clone(), status,
tool: CollabAgentTool::CloseAgent, sender_thread_id: payload.sender_thread_id.to_string(),
status, receiver_thread_ids: vec![receiver_id],
sender_thread_id: payload.sender_thread_id.to_string(), prompt: None,
receiver_thread_ids: vec![receiver_id], agents_states,
prompt: None, });
agents_states, }
});
fn handle_collab_resume_begin(
&mut self,
payload: &codex_protocol::protocol::CollabResumeBeginEvent,
) {
let item = ThreadItem::CollabAgentToolCall {
id: payload.call_id.clone(),
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::InProgress,
sender_thread_id: payload.sender_thread_id.to_string(),
receiver_thread_ids: vec![payload.receiver_thread_id.to_string()],
prompt: None,
agents_states: HashMap::new(),
};
self.upsert_item_in_current_turn(item);
} }
fn handle_collab_resume_end( fn handle_collab_resume_end(
@@ -463,17 +656,15 @@ impl ThreadHistoryBuilder {
)] )]
.into_iter() .into_iter()
.collect(); .collect();
self.ensure_turn() self.upsert_item_in_current_turn(ThreadItem::CollabAgentToolCall {
.items id: payload.call_id.clone(),
.push(ThreadItem::CollabAgentToolCall { tool: CollabAgentTool::ResumeAgent,
id: payload.call_id.clone(), status,
tool: CollabAgentTool::ResumeAgent, sender_thread_id: payload.sender_thread_id.to_string(),
status, receiver_thread_ids: vec![receiver_id],
sender_thread_id: payload.sender_thread_id.to_string(), prompt: None,
receiver_thread_ids: vec![receiver_id], agents_states,
prompt: None, });
agents_states,
});
} }
fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) { fn handle_context_compacted(&mut self, _payload: &ContextCompactedEvent) {
@@ -548,6 +739,7 @@ impl ThreadHistoryBuilder {
self.finish_current_turn(); self.finish_current_turn();
self.current_turn = Some( self.current_turn = Some(
self.new_turn(Some(payload.turn_id.clone())) self.new_turn(Some(payload.turn_id.clone()))
.with_status(TurnStatus::InProgress)
.opened_explicitly(), .opened_explicitly(),
); );
} }
@@ -642,6 +834,30 @@ impl ThreadHistoryBuilder {
unreachable!("current turn must exist after initialization"); unreachable!("current turn must exist after initialization");
} }
fn upsert_item_in_turn_id(&mut self, turn_id: &str, item: ThreadItem) {
if let Some(turn) = self.current_turn.as_mut()
&& turn.id == turn_id
{
upsert_turn_item(&mut turn.items, item);
return;
}
if let Some(turn) = self.turns.iter_mut().find(|turn| turn.id == turn_id) {
upsert_turn_item(&mut turn.items, item);
return;
}
warn!(
item_id = item.id(),
"dropping turn-scoped item for unknown turn id `{turn_id}`"
);
}
fn upsert_item_in_current_turn(&mut self, item: ThreadItem) {
let turn = self.ensure_turn();
upsert_turn_item(&mut turn.items, item);
}
fn next_item_id(&mut self) -> String { fn next_item_id(&mut self) -> String {
let id = format!("item-{}", self.next_item_index); let id = format!("item-{}", self.next_item_index);
self.next_item_index += 1; self.next_item_index += 1;
@@ -684,7 +900,7 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
} }
} }
fn convert_patch_changes( pub fn convert_patch_changes(
changes: &HashMap<std::path::PathBuf, codex_protocol::protocol::FileChange>, changes: &HashMap<std::path::PathBuf, codex_protocol::protocol::FileChange>,
) -> Vec<FileUpdateChange> { ) -> Vec<FileUpdateChange> {
let mut converted: Vec<FileUpdateChange> = changes let mut converted: Vec<FileUpdateChange> = changes
@@ -726,6 +942,17 @@ fn format_file_change_diff(change: &codex_protocol::protocol::FileChange) -> Str
} }
} }
fn upsert_turn_item(items: &mut Vec<ThreadItem>, item: ThreadItem) {
if let Some(existing_item) = items
.iter_mut()
.find(|existing_item| existing_item.id() == item.id())
{
*existing_item = item;
return;
}
items.push(item);
}
struct PendingTurn { struct PendingTurn {
id: String, id: String,
items: Vec<ThreadItem>, items: Vec<ThreadItem>,
@@ -744,6 +971,11 @@ impl PendingTurn {
self.opened_explicitly = true; self.opened_explicitly = true;
self self
} }
fn with_status(mut self, status: TurnStatus) -> Self {
self.status = status;
self
}
} }
impl From<PendingTurn> for Turn { impl From<PendingTurn> for Turn {
@@ -757,10 +989,23 @@ impl From<PendingTurn> for Turn {
} }
} }
impl From<&PendingTurn> for Turn {
fn from(value: &PendingTurn) -> Self {
Self {
id: value.id.clone(),
items: value.items.clone(),
error: value.error.clone(),
status: value.status.clone(),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::items::UserMessageItem as CoreUserMessageItem;
use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::MessagePhase as CoreMessagePhase;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::parse_command::ParsedCommand; use codex_protocol::parse_command::ParsedCommand;
@@ -771,6 +1016,7 @@ mod tests {
use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandEndEvent;
use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::ItemStartedEvent;
use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::ThreadRolledBackEvent;
@@ -816,11 +1062,11 @@ mod tests {
}), }),
]; ];
let items = events let mut builder = ThreadHistoryBuilder::new();
.into_iter() for event in &events {
.map(RolloutItem::EventMsg) builder.handle_event(event);
.collect::<Vec<_>>(); }
let turns = build_turns_from_rollout_items(&items); let turns = builder.finish();
assert_eq!(turns.len(), 2); assert_eq!(turns.len(), 2);
let first = &turns[0]; let first = &turns[0];
@@ -883,6 +1129,55 @@ mod tests {
); );
} }
#[test]
fn ignores_non_plan_item_lifecycle_events() {
let turn_id = "turn-1";
let thread_id = ThreadId::new();
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "hello".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::ItemStarted(ItemStartedEvent {
thread_id,
turn_id: turn_id.to_string(),
item: CoreTurnItem::UserMessage(CoreUserMessageItem {
id: "user-item-id".to_string(),
content: Vec::new(),
}),
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_id.to_string(),
last_agent_message: None,
}),
];
let items = events
.into_iter()
.map(RolloutItem::EventMsg)
.collect::<Vec<_>>();
let turns = build_turns_from_rollout_items(&items);
assert_eq!(turns.len(), 1);
assert_eq!(turns[0].items.len(), 1);
assert_eq!(
turns[0].items[0],
ThreadItem::UserMessage {
id: "item-1".into(),
content: vec![UserInput::Text {
text: "hello".into(),
text_elements: Vec::new(),
}],
}
);
}
#[test] #[test]
fn preserves_agent_message_phase_in_history() { fn preserves_agent_message_phase_in_history() {
let events = vec![EventMsg::AgentMessage(AgentMessageEvent { let events = vec![EventMsg::AgentMessage(AgentMessageEvent {
@@ -1212,6 +1507,11 @@ mod tests {
#[test] #[test]
fn reconstructs_tool_items_from_persisted_completion_events() { fn reconstructs_tool_items_from_persisted_completion_events() {
let events = vec![ let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent { EventMsg::UserMessage(UserMessageEvent {
message: "run tools".into(), message: "run tools".into(),
images: None, images: None,
@@ -1311,6 +1611,11 @@ mod tests {
#[test] #[test]
fn reconstructs_declined_exec_and_patch_items() { fn reconstructs_declined_exec_and_patch_items() {
let events = vec![ let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent { EventMsg::UserMessage(UserMessageEvent {
message: "run tools".into(), message: "run tools".into(),
images: None, images: None,
@@ -1471,6 +1776,82 @@ mod tests {
); );
} }
#[test]
fn drops_late_turn_scoped_item_for_unknown_turn_id() {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "first".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-a".into(),
last_agent_message: None,
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
model_context_window: None,
collaboration_mode_kind: Default::default(),
}),
EventMsg::UserMessage(UserMessageEvent {
message: "second".into(),
images: None,
text_elements: Vec::new(),
local_images: Vec::new(),
}),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: "exec-unknown-turn".into(),
process_id: Some("pid-42".into()),
turn_id: "turn-missing".into(),
command: vec!["echo".into(), "done".into()],
cwd: PathBuf::from("/tmp"),
parsed_cmd: vec![ParsedCommand::Unknown {
cmd: "echo done".into(),
}],
source: ExecCommandSource::Agent,
interaction_input: None,
stdout: "done\n".into(),
stderr: String::new(),
aggregated_output: "done\n".into(),
exit_code: 0,
duration: Duration::from_millis(5),
formatted_output: "done\n".into(),
status: CoreExecCommandStatus::Completed,
}),
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-b".into(),
last_agent_message: None,
}),
];
let mut builder = ThreadHistoryBuilder::new();
for event in &events {
builder.handle_event(event);
}
let turns = builder.finish();
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].id, "turn-a");
assert_eq!(turns[1].id, "turn-b");
assert_eq!(turns[0].items.len(), 1);
assert_eq!(turns[1].items.len(), 1);
assert_eq!(
turns[1].items[0],
ThreadItem::UserMessage {
id: "item-2".into(),
content: vec![UserInput::Text {
text: "second".into(),
text_elements: Vec::new(),
}],
}
);
}
#[test] #[test]
fn late_turn_complete_does_not_close_active_turn() { fn late_turn_complete_does_not_close_active_turn() {
let events = vec![ let events = vec![
@@ -1572,7 +1953,7 @@ mod tests {
assert_eq!(turns.len(), 2); assert_eq!(turns.len(), 2);
assert_eq!(turns[0].id, "turn-a"); assert_eq!(turns[0].id, "turn-a");
assert_eq!(turns[1].id, "turn-b"); assert_eq!(turns[1].id, "turn-b");
assert_eq!(turns[1].status, TurnStatus::Completed); assert_eq!(turns[1].status, TurnStatus::InProgress);
assert_eq!(turns[1].items.len(), 2); assert_eq!(turns[1].items.len(), 2);
} }
@@ -2727,6 +2727,26 @@ pub enum ThreadItem {
ContextCompaction { id: String }, ContextCompaction { id: String },
} }
impl ThreadItem {
pub fn id(&self) -> &str {
match self {
ThreadItem::UserMessage { id, .. }
| ThreadItem::AgentMessage { id, .. }
| ThreadItem::Plan { id, .. }
| ThreadItem::Reasoning { id, .. }
| ThreadItem::CommandExecution { id, .. }
| ThreadItem::FileChange { id, .. }
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::WebSearch { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")] #[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")] #[ts(tag = "type", rename_all = "camelCase")]
@@ -45,7 +45,6 @@ use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind as V2PatchChangeKind;
use codex_app_server_protocol::PlanDeltaNotification; use codex_app_server_protocol::PlanDeltaNotification;
use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryPartAddedNotification; use codex_app_server_protocol::ReasoningSummaryPartAddedNotification;
@@ -72,6 +71,7 @@ use codex_app_server_protocol::TurnPlanStep;
use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnPlanUpdatedNotification;
use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_app_server_protocol::convert_patch_changes;
use codex_core::CodexThread; use codex_core::CodexThread;
use codex_core::ThreadManager; use codex_core::ThreadManager;
use codex_core::parse_command::shlex_join; use codex_core::parse_command::shlex_join;
@@ -81,7 +81,6 @@ use codex_core::protocol::Event;
use codex_core::protocol::EventMsg; use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::FileChange as CoreFileChange;
use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op; use codex_core::protocol::Op;
@@ -1706,46 +1705,6 @@ fn render_review_output_text(output: &ReviewOutputEvent) -> String {
} }
} }
fn convert_patch_changes(changes: &HashMap<PathBuf, CoreFileChange>) -> Vec<FileUpdateChange> {
let mut converted: Vec<FileUpdateChange> = changes
.iter()
.map(|(path, change)| FileUpdateChange {
path: path.to_string_lossy().into_owned(),
kind: map_patch_change_kind(change),
diff: format_file_change_diff(change),
})
.collect();
converted.sort_by(|a, b| a.path.cmp(&b.path));
converted
}
fn map_patch_change_kind(change: &CoreFileChange) -> V2PatchChangeKind {
match change {
CoreFileChange::Add { .. } => V2PatchChangeKind::Add,
CoreFileChange::Delete { .. } => V2PatchChangeKind::Delete,
CoreFileChange::Update { move_path, .. } => V2PatchChangeKind::Update {
move_path: move_path.clone(),
},
}
}
fn format_file_change_diff(change: &CoreFileChange) -> String {
match change {
CoreFileChange::Add { content } => content.clone(),
CoreFileChange::Delete { content } => content.clone(),
CoreFileChange::Update {
unified_diff,
move_path,
} => {
if let Some(path) = move_path {
format!("{unified_diff}\n\nMoved to: {}", path.display())
} else {
unified_diff.clone()
}
}
}
}
fn map_file_change_approval_decision( fn map_file_change_approval_decision(
decision: FileChangeApprovalDecision, decision: FileChangeApprovalDecision,
) -> (ReviewDecision, Option<PatchApplyStatus>) { ) -> (ReviewDecision, Option<PatchApplyStatus>) {
@@ -336,6 +336,7 @@ pub(crate) struct CodexMessageProcessor {
outgoing: Arc<OutgoingMessageSender>, outgoing: Arc<OutgoingMessageSender>,
codex_linux_sandbox_exe: Option<PathBuf>, codex_linux_sandbox_exe: Option<PathBuf>,
config: Arc<Config>, config: Arc<Config>,
single_client_mode: bool,
cli_overrides: Vec<(String, TomlValue)>, cli_overrides: Vec<(String, TomlValue)>,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>, cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
active_login: Arc<Mutex<Option<ActiveLogin>>>, active_login: Arc<Mutex<Option<ActiveLogin>>>,
@@ -361,6 +362,7 @@ pub(crate) struct CodexMessageProcessorArgs {
pub(crate) config: Arc<Config>, pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>, pub(crate) cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
pub(crate) single_client_mode: bool,
pub(crate) feedback: CodexFeedback, pub(crate) feedback: CodexFeedback,
} }
@@ -397,6 +399,7 @@ impl CodexMessageProcessor {
config, config,
cli_overrides, cli_overrides,
cloud_requirements, cloud_requirements,
single_client_mode,
feedback, feedback,
} = args; } = args;
Self { Self {
@@ -405,6 +408,7 @@ impl CodexMessageProcessor {
outgoing: outgoing.clone(), outgoing: outgoing.clone(),
codex_linux_sandbox_exe, codex_linux_sandbox_exe,
config, config,
single_client_mode,
cli_overrides, cli_overrides,
cloud_requirements, cloud_requirements,
active_login: Arc::new(Mutex::new(None)), active_login: Arc::new(Mutex::new(None)),
@@ -3042,21 +3046,14 @@ impl CodexMessageProcessor {
return true; return true;
} }
if let Err(err) = self let thread_state = self.thread_state_manager.thread_state(existing_thread_id);
.ensure_conversation_listener( self.ensure_listener_task_running(
existing_thread_id, existing_thread_id,
request_id.connection_id, existing_thread.clone(),
false, thread_state.clone(),
ApiVersion::V2, ApiVersion::V2,
) )
.await .await;
{
tracing::warn!(
"failed to attach listener for thread {}: {}",
existing_thread_id,
err.message
);
}
let config_snapshot = existing_thread.config_snapshot().await; let config_snapshot = existing_thread.config_snapshot().await;
let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot);
@@ -3068,41 +3065,39 @@ impl CodexMessageProcessor {
); );
} }
let Some(mut thread) = self let listener_command_tx = {
.load_thread_from_rollout_or_send_internal( let thread_state = thread_state.lock().await;
request_id.clone(), thread_state.listener_command_tx()
existing_thread_id, };
rollout_path.as_path(), let Some(listener_command_tx) = listener_command_tx else {
config_snapshot.model_provider_id.as_str(), let err = JSONRPCErrorError {
) code: INTERNAL_ERROR_CODE,
.await message: format!(
else { "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running"
),
data: None,
};
self.outgoing.send_error(request_id, err).await;
return true; return true;
}; };
let ThreadConfigSnapshot { let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
model, crate::thread_state::PendingThreadResumeRequest {
model_provider_id, request_id: request_id.clone(),
approval_policy, rollout_path,
sandbox_policy, config_snapshot,
cwd, },
reasoning_effort, );
.. if listener_command_tx.send(command).is_err() {
} = config_snapshot; let err = JSONRPCErrorError {
thread.status = self code: INTERNAL_ERROR_CODE,
.thread_watch_manager message: format!(
.loaded_status_for_thread(&thread.id) "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed"
.await; ),
let response = ThreadResumeResponse { data: None,
thread, };
model, self.outgoing.send_error(request_id, err).await;
model_provider: model_provider_id, }
cwd,
approval_policy: approval_policy.into(),
sandbox: sandbox_policy.into(),
reasoning_effort,
};
self.outgoing.send_response(request_id, response).await;
return true; return true;
} }
false false
@@ -5817,17 +5812,18 @@ impl CodexMessageProcessor {
api_version: ApiVersion, api_version: ApiVersion,
) { ) {
let (cancel_tx, mut cancel_rx) = oneshot::channel(); let (cancel_tx, mut cancel_rx) = oneshot::channel();
{ let mut listener_command_rx = {
let mut thread_state = thread_state.lock().await; let mut thread_state = thread_state.lock().await;
if thread_state.listener_matches(&conversation) { if thread_state.listener_matches(&conversation) {
return; return;
} }
thread_state.set_listener(cancel_tx, &conversation); thread_state.set_listener(cancel_tx, &conversation)
} };
let outgoing_for_task = self.outgoing.clone(); let outgoing_for_task = self.outgoing.clone();
let thread_manager = self.thread_manager.clone(); let thread_manager = self.thread_manager.clone();
let thread_watch_manager = self.thread_watch_manager.clone(); let thread_watch_manager = self.thread_watch_manager.clone();
let fallback_model_provider = self.config.model_provider_id.clone(); let fallback_model_provider = self.config.model_provider_id.clone();
let single_client_mode = self.single_client_mode;
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
tokio::select! { tokio::select! {
@@ -5869,7 +5865,10 @@ impl CodexMessageProcessor {
conversation_id.to_string().into(), conversation_id.to_string().into(),
); );
let (subscribed_connection_ids, raw_events_enabled) = { let (subscribed_connection_ids, raw_events_enabled) = {
let thread_state = thread_state.lock().await; let mut thread_state = thread_state.lock().await;
if !single_client_mode {
thread_state.track_current_turn_event(&event.msg);
}
( (
thread_state.subscribed_connection_ids(), thread_state.subscribed_connection_ids(),
thread_state.experimental_raw_events, thread_state.experimental_raw_events,
@@ -5908,6 +5907,25 @@ impl CodexMessageProcessor {
) )
.await; .await;
} }
listener_command = listener_command_rx.recv() => {
let Some(listener_command) = listener_command else {
break;
};
match listener_command {
crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
resume_request,
) => {
handle_pending_thread_resume_request(
conversation_id,
&thread_state,
&thread_watch_manager,
&outgoing_for_task,
resume_request,
)
.await;
}
}
}
} }
} }
}); });
@@ -6206,6 +6224,106 @@ impl CodexMessageProcessor {
} }
} }
async fn handle_pending_thread_resume_request(
conversation_id: ThreadId,
thread_state: &Arc<Mutex<ThreadState>>,
thread_watch_manager: &ThreadWatchManager,
outgoing: &Arc<OutgoingMessageSender>,
pending: crate::thread_state::PendingThreadResumeRequest,
) {
let active_turn = {
let state = thread_state.lock().await;
state.active_turn_snapshot()
};
let request_id = pending.request_id;
let connection_id = request_id.connection_id;
let mut thread = match load_thread_for_running_resume_response(
conversation_id,
pending.rollout_path.as_path(),
pending.config_snapshot.model_provider_id.as_str(),
active_turn.as_ref(),
)
.await
{
Ok(thread) => thread,
Err(message) => {
outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message,
data: None,
},
)
.await;
return;
}
};
thread.status = thread_watch_manager
.loaded_status_for_thread(&thread.id)
.await;
let ThreadConfigSnapshot {
model,
model_provider_id,
approval_policy,
sandbox_policy,
cwd,
reasoning_effort,
..
} = pending.config_snapshot;
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
cwd,
approval_policy: approval_policy.into(),
sandbox: sandbox_policy.into(),
reasoning_effort,
};
outgoing.send_response(request_id, response).await;
thread_state.lock().await.add_connection(connection_id);
}
async fn load_thread_for_running_resume_response(
conversation_id: ThreadId,
rollout_path: &Path,
fallback_provider: &str,
active_turn: Option<&Turn>,
) -> std::result::Result<Thread, String> {
let mut thread = read_summary_from_rollout(rollout_path, fallback_provider)
.await
.map(summary_to_thread)
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
let mut turns = read_rollout_items_from_rollout(rollout_path)
.await
.map(|items| build_turns_from_rollout_items(&items))
.map_err(|err| {
format!(
"failed to load rollout `{}` for thread {conversation_id}: {err}",
rollout_path.display()
)
})?;
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
thread.turns = turns;
Ok(thread)
}
fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) {
turns.retain(|turn| turn.id != active_turn.id);
turns.push(active_turn);
}
fn collect_resume_override_mismatches( fn collect_resume_override_mismatches(
request: &ThreadResumeParams, request: &ThreadResumeParams,
config_snapshot: &ThreadConfigSnapshot, config_snapshot: &ThreadConfigSnapshot,
+3 -1
View File
@@ -238,7 +238,8 @@ pub async fn run_main_with_transport(
Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?); Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?);
} }
} }
let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio); let single_client_mode = matches!(transport, AppServerTransport::Stdio);
let shutdown_when_no_connections = single_client_mode;
// Parse CLI overrides once and derive the base Config eagerly so later // Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values. // components do not need to work with raw TOML values.
@@ -439,6 +440,7 @@ pub async fn run_main_with_transport(
outgoing: outgoing_message_sender, outgoing: outgoing_message_sender,
codex_linux_sandbox_exe, codex_linux_sandbox_exe,
config: Arc::new(config), config: Arc::new(config),
single_client_mode,
cli_overrides, cli_overrides,
loader_overrides, loader_overrides,
cloud_requirements: cloud_requirements.clone(), cloud_requirements: cloud_requirements.clone(),
@@ -139,6 +139,7 @@ pub(crate) struct MessageProcessorArgs {
pub(crate) outgoing: Arc<OutgoingMessageSender>, pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>, pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) config: Arc<Config>, pub(crate) config: Arc<Config>,
pub(crate) single_client_mode: bool,
pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cli_overrides: Vec<(String, TomlValue)>,
pub(crate) loader_overrides: LoaderOverrides, pub(crate) loader_overrides: LoaderOverrides,
pub(crate) cloud_requirements: CloudRequirementsLoader, pub(crate) cloud_requirements: CloudRequirementsLoader,
@@ -154,6 +155,7 @@ impl MessageProcessor {
outgoing, outgoing,
codex_linux_sandbox_exe, codex_linux_sandbox_exe,
config, config,
single_client_mode,
cli_overrides, cli_overrides,
loader_overrides, loader_overrides,
cloud_requirements, cloud_requirements,
@@ -184,6 +186,7 @@ impl MessageProcessor {
config: Arc::clone(&config), config: Arc::clone(&config),
cli_overrides: cli_overrides.clone(), cli_overrides: cli_overrides.clone(),
cloud_requirements: cloud_requirements.clone(), cloud_requirements: cloud_requirements.clone(),
single_client_mode,
feedback, feedback,
}); });
let config_api = ConfigApi::new( let config_api = ConfigApi::new(
+56 -4
View File
@@ -1,13 +1,19 @@
use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnError;
use codex_core::CodexThread; use codex_core::CodexThread;
use codex_core::ThreadConfigSnapshot;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Weak; use std::sync::Weak;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use uuid::Uuid; use uuid::Uuid;
@@ -16,6 +22,16 @@ type PendingInterruptQueue = Vec<(
crate::codex_message_processor::ApiVersion, crate::codex_message_processor::ApiVersion,
)>; )>;
pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
}
pub(crate) enum ThreadListenerCommand {
SendThreadResumeResponse(PendingThreadResumeRequest),
}
/// Per-conversation accumulation of the latest states e.g. error message while a turn runs. /// Per-conversation accumulation of the latest states e.g. error message while a turn runs.
#[derive(Default, Clone)] #[derive(Default, Clone)]
pub(crate) struct TurnSummary { pub(crate) struct TurnSummary {
@@ -31,6 +47,8 @@ pub(crate) struct ThreadState {
pub(crate) turn_summary: TurnSummary, pub(crate) turn_summary: TurnSummary,
pub(crate) cancel_tx: Option<oneshot::Sender<()>>, pub(crate) cancel_tx: Option<oneshot::Sender<()>>,
pub(crate) experimental_raw_events: bool, pub(crate) experimental_raw_events: bool,
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
listener_thread: Option<Weak<CodexThread>>, listener_thread: Option<Weak<CodexThread>>,
subscribed_connections: HashSet<ConnectionId>, subscribed_connections: HashSet<ConnectionId>,
} }
@@ -47,17 +65,22 @@ impl ThreadState {
&mut self, &mut self,
cancel_tx: oneshot::Sender<()>, cancel_tx: oneshot::Sender<()>,
conversation: &Arc<CodexThread>, conversation: &Arc<CodexThread>,
) { ) -> mpsc::UnboundedReceiver<ThreadListenerCommand> {
if let Some(previous) = self.cancel_tx.replace(cancel_tx) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
let _ = previous.send(()); let _ = previous.send(());
} }
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
self.listener_command_tx = Some(listener_command_tx);
self.listener_thread = Some(Arc::downgrade(conversation)); self.listener_thread = Some(Arc::downgrade(conversation));
listener_command_rx
} }
pub(crate) fn clear_listener(&mut self) { pub(crate) fn clear_listener(&mut self) {
if let Some(cancel_tx) = self.cancel_tx.take() { if let Some(cancel_tx) = self.cancel_tx.take() {
let _ = cancel_tx.send(()); let _ = cancel_tx.send(());
} }
self.listener_command_tx = None;
self.current_turn_history.reset();
self.listener_thread = None; self.listener_thread = None;
} }
@@ -76,6 +99,23 @@ impl ThreadState {
pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) { pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) {
self.experimental_raw_events = enabled; self.experimental_raw_events = enabled;
} }
pub(crate) fn listener_command_tx(
&self,
) -> Option<mpsc::UnboundedSender<ThreadListenerCommand>> {
self.listener_command_tx.clone()
}
pub(crate) fn active_turn_snapshot(&self) -> Option<Turn> {
self.current_turn_history.active_turn_snapshot()
}
pub(crate) fn track_current_turn_event(&mut self, event: &EventMsg) {
self.current_turn_history.handle_event(event);
if !self.current_turn_history.has_active_turn() {
self.current_turn_history.reset();
}
}
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -200,12 +240,24 @@ impl ThreadStateManager {
} }
pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) { pub(crate) async fn remove_connection(&mut self, connection_id: ConnectionId) {
let Some(thread_ids) = self.thread_ids_by_connection.remove(&connection_id) else { let thread_ids = self
return; .thread_ids_by_connection
}; .remove(&connection_id)
.unwrap_or_default();
self.subscription_state_by_id self.subscription_state_by_id
.retain(|_, state| state.connection_id != connection_id); .retain(|_, state| state.connection_id != connection_id);
if thread_ids.is_empty() {
for thread_state in self.thread_states.values() {
let mut thread_state = thread_state.lock().await;
thread_state.remove_connection(connection_id);
if thread_state.subscribed_connection_ids().is_empty() {
thread_state.clear_listener();
}
}
return;
}
for thread_id in thread_ids { for thread_id in thread_ids {
if let Some(thread_state) = self.thread_states.get(&thread_id) { if let Some(thread_state) = self.thread_states.get(&thread_id) {
let mut thread_state = thread_state.lock().await; let mut thread_state = thread_state.lock().await;