mirror of
https://github.com/openai/codex.git
synced 2026-11-13 21:30:47 +00:00
## Why MCP servers registered under the same name in different environments need distinct inventory, routing, and status reporting. ## What changed - Keep MCP inventory and runtime status keyed by registration scope so same-named servers remain separate. - Add optional `environmentId` selectors for status lookup, tool calls, resource reads, and OAuth login. Omitting the selector preserves existing name-only routing. - Carry environment identity through startup notifications, elicitation requests, and tool-call history, and display it in the TUI. - Support `/mcp login <name> --environment <id>` and update generated protocol schemas and SDK types. ## Testing Add integration coverage for two environments with identically named MCP servers, tools, and resource URIs. Verify separate inventory, explicit environment routing, legacy routing when the selector is omitted, and errors for unknown environments. Update protocol and TUI tests for environment identity. GitOrigin-RevId: 074a3db10549438cb6aff33eb7bed10791adae4d
1825 lines
65 KiB
Rust
1825 lines
65 KiB
Rust
use codex_app_server_protocol::CollabAgentState as ApiCollabAgentState;
|
|
use codex_app_server_protocol::CollabAgentStatus as ApiCollabAgentStatus;
|
|
use codex_app_server_protocol::CollabAgentTool;
|
|
use codex_app_server_protocol::CollabAgentToolCallStatus as ApiCollabAgentToolCallStatus;
|
|
use codex_app_server_protocol::CommandAction;
|
|
use codex_app_server_protocol::CommandExecutionSource;
|
|
use codex_app_server_protocol::CommandExecutionStatus as ApiCommandExecutionStatus;
|
|
use codex_app_server_protocol::ErrorNotification;
|
|
use codex_app_server_protocol::FileUpdateChange as ApiFileUpdateChange;
|
|
use codex_app_server_protocol::ItemCompletedNotification;
|
|
use codex_app_server_protocol::ItemStartedNotification;
|
|
use codex_app_server_protocol::McpToolCallError;
|
|
use codex_app_server_protocol::McpToolCallResult;
|
|
use codex_app_server_protocol::McpToolCallStatus as ApiMcpToolCallStatus;
|
|
use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus;
|
|
use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadItem;
|
|
use codex_app_server_protocol::ThreadItemPayload;
|
|
use codex_app_server_protocol::ThreadTokenUsage;
|
|
use codex_app_server_protocol::TokenUsageBreakdown;
|
|
use codex_app_server_protocol::Turn;
|
|
use codex_app_server_protocol::TurnCompletedNotification;
|
|
use codex_app_server_protocol::TurnError;
|
|
use codex_app_server_protocol::TurnPlanStep;
|
|
use codex_app_server_protocol::TurnPlanStepStatus;
|
|
use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
|
use codex_app_server_protocol::TurnStartedNotification;
|
|
use codex_app_server_protocol::TurnStatus;
|
|
use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction;
|
|
use codex_app_server_protocol::WebSearchItem as ApiWebSearchItem;
|
|
use codex_protocol::SessionId;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::models::WebSearchAction;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::SessionConfiguredEvent;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use codex_utils_absolute_path::test_support::test_path_buf;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
|
|
use codex_exec::AgentMessageItem;
|
|
use codex_exec::CodexStatus;
|
|
use codex_exec::CollabAgentState;
|
|
use codex_exec::CollabAgentStatus;
|
|
use codex_exec::CollabTool;
|
|
use codex_exec::CollabToolCallItem;
|
|
use codex_exec::CollabToolCallStatus;
|
|
use codex_exec::CollectedThreadEvents;
|
|
use codex_exec::CommandExecutionItem;
|
|
use codex_exec::CommandExecutionStatus;
|
|
use codex_exec::ErrorItem;
|
|
use codex_exec::EventProcessorWithJsonOutput;
|
|
use codex_exec::ExecThreadItem;
|
|
use codex_exec::FileChangeItem;
|
|
use codex_exec::FileUpdateChange as ExecFileUpdateChange;
|
|
use codex_exec::ItemCompletedEvent;
|
|
use codex_exec::ItemStartedEvent;
|
|
use codex_exec::ItemUpdatedEvent;
|
|
use codex_exec::McpToolCallItem;
|
|
use codex_exec::McpToolCallItemError;
|
|
use codex_exec::McpToolCallItemResult;
|
|
use codex_exec::McpToolCallStatus;
|
|
use codex_exec::PatchApplyStatus;
|
|
use codex_exec::PatchChangeKind;
|
|
use codex_exec::ReasoningItem;
|
|
use codex_exec::ThreadErrorEvent;
|
|
use codex_exec::ThreadEvent;
|
|
use codex_exec::ThreadItemDetails;
|
|
use codex_exec::ThreadStartedEvent;
|
|
use codex_exec::TodoItem;
|
|
use codex_exec::TodoListItem;
|
|
use codex_exec::TurnCompletedEvent;
|
|
use codex_exec::TurnFailedEvent;
|
|
use codex_exec::TurnStartedEvent;
|
|
use codex_exec::Usage;
|
|
use codex_exec::WebSearchItem;
|
|
|
|
#[test]
|
|
fn map_todo_items_preserves_text_and_completion_state() {
|
|
let items = EventProcessorWithJsonOutput::map_todo_items(&[
|
|
TurnPlanStep {
|
|
step: "inspect bootstrap".to_string(),
|
|
status: TurnPlanStepStatus::InProgress,
|
|
},
|
|
TurnPlanStep {
|
|
step: "drop legacy notifications".to_string(),
|
|
status: TurnPlanStepStatus::Completed,
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
items,
|
|
vec![
|
|
TodoItem {
|
|
text: "inspect bootstrap".to_string(),
|
|
completed: false,
|
|
},
|
|
TodoItem {
|
|
text: "drop legacy notifications".to_string(),
|
|
completed: true,
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn session_configured_produces_thread_started_event() {
|
|
let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")
|
|
.expect("thread id should parse");
|
|
let session_configured = SessionConfiguredEvent {
|
|
session_id: SessionId::from(thread_id),
|
|
thread_id,
|
|
forked_from_id: None,
|
|
parent_thread_id: None,
|
|
thread_source: None,
|
|
thread_name: None,
|
|
model: "codex-mini-latest".to_string(),
|
|
model_provider_id: "test-provider".to_string(),
|
|
service_tier: None,
|
|
approval_policy: AskForApproval::Never,
|
|
approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User,
|
|
permission_profile: PermissionProfile::read_only(),
|
|
active_permission_profile: None,
|
|
cwd: test_path_buf("/tmp/project").abs(),
|
|
reasoning_effort: None,
|
|
network_proxy: None,
|
|
rollout_path: None,
|
|
};
|
|
|
|
assert_eq!(
|
|
EventProcessorWithJsonOutput::thread_started_event(&session_configured),
|
|
ThreadEvent::ThreadStarted(ThreadStartedEvent {
|
|
thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_started_emits_turn_started_event() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected =
|
|
processor.collect_thread_events(ServerNotification::TurnStarted(TurnStartedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::InProgress,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
}));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnStarted(TurnStartedEvent {})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn command_execution_started_and_completed_translate_to_thread_events() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
let command_item = ThreadItemPayload::CommandExecution {
|
|
model_context: None,
|
|
sandbox_type: None,
|
|
id: "cmd-1".to_string(),
|
|
command: "ls".to_string(),
|
|
cwd: test_path_buf("/tmp/project").abs().into(),
|
|
process_id: Some("123".to_string()),
|
|
plugin_id: None,
|
|
script_path: None,
|
|
source: CommandExecutionSource::UserShell,
|
|
status: ApiCommandExecutionStatus::InProgress,
|
|
command_actions: Vec::<CommandAction>::new(),
|
|
aggregated_output: None,
|
|
exit_code: None,
|
|
duration_ms: None,
|
|
};
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
command_item,
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
|
command: "ls".to_string(),
|
|
aggregated_output: String::new(),
|
|
exit_code: None,
|
|
status: CommandExecutionStatus::InProgress,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::CommandExecution {
|
|
model_context: None,
|
|
sandbox_type: None,
|
|
id: "cmd-1".to_string(),
|
|
command: "ls".to_string(),
|
|
cwd: test_path_buf("/tmp/project").abs().into(),
|
|
process_id: Some("123".to_string()),
|
|
plugin_id: None,
|
|
script_path: None,
|
|
source: CommandExecutionSource::UserShell,
|
|
status: ApiCommandExecutionStatus::Completed,
|
|
command_actions: Vec::<CommandAction>::new(),
|
|
aggregated_output: Some("a.txt\n".to_string()),
|
|
exit_code: Some(0),
|
|
duration_ms: Some(3),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
|
command: "ls".to_string(),
|
|
aggregated_output: "a.txt\n".to_string(),
|
|
exit_code: Some(0),
|
|
status: CommandExecutionStatus::Completed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_reasoning_items_are_ignored() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: Vec::new(),
|
|
content: vec!["raw reasoning".to_string()],
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: Vec::new(),
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unsupported_items_do_not_consume_synthetic_ids() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::Plan {
|
|
id: "plan-1".to_string(),
|
|
text: "ignored plan".to_string(),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
ignored,
|
|
CollectedThreadEvents {
|
|
events: Vec::new(),
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "message-1".to_string(),
|
|
text: "hello".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
|
|
text: "hello".to_string(),
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_items_emit_summary_not_raw_content() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::Reasoning {
|
|
id: "reasoning-1".to_string(),
|
|
summary: vec!["safe summary".to_string()],
|
|
content: vec!["raw reasoning".to_string()],
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::Reasoning(ReasoningItem {
|
|
text: "safe summary".to_string(),
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_completion_preserves_query_and_action() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::WebSearch(ApiWebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: "rust async await".to_string(),
|
|
action: Some(ApiWebSearchAction::Search {
|
|
query: Some("rust async await".to_string()),
|
|
queries: None,
|
|
}),
|
|
results: None,
|
|
}),
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::WebSearch(WebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: "rust async await".to_string(),
|
|
action: WebSearchAction::Search {
|
|
query: Some("rust async await".to_string()),
|
|
queries: None,
|
|
},
|
|
results: None,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_page_actions_and_results_survive_json_output() {
|
|
let url = "https://example.com/docs";
|
|
for (action, expected_action) in [
|
|
(
|
|
ApiWebSearchAction::OpenPage {
|
|
url: Some(url.to_string()),
|
|
},
|
|
json!({"type": "open_page", "url": url}),
|
|
),
|
|
(
|
|
ApiWebSearchAction::FindInPage {
|
|
url: Some(url.to_string()),
|
|
pattern: Some("configuration".to_string()),
|
|
},
|
|
json!({"type": "find_in_page", "url": url, "pattern": "configuration"}),
|
|
),
|
|
] {
|
|
for results in [
|
|
None,
|
|
Some(vec![]),
|
|
Some(vec![json!({"url": url, "content": "configuration"})]),
|
|
Some(vec![json!({"url": url, "error": {"status": 404}})]),
|
|
] {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::WebSearch(ApiWebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: url.to_string(),
|
|
action: Some(action.clone()),
|
|
results: results.clone(),
|
|
}),
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
let mut expected_item = json!({
|
|
"id": "search-1",
|
|
"type": "web_search",
|
|
"query": url,
|
|
"action": expected_action,
|
|
});
|
|
if let Some(results) = results {
|
|
expected_item["results"] = json!(results);
|
|
}
|
|
assert_eq!(
|
|
serde_json::to_value(collected.events).expect("serialize web search events"),
|
|
json!([{"type": "item.completed", "item": expected_item}]),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_start_and_completion_reuse_item_id() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::WebSearch(ApiWebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: String::new(),
|
|
action: None,
|
|
results: None,
|
|
}),
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::WebSearch(ApiWebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: "rust async await".to_string(),
|
|
action: Some(ApiWebSearchAction::Search {
|
|
query: Some("rust async await".to_string()),
|
|
queries: None,
|
|
}),
|
|
results: None,
|
|
}),
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::WebSearch(WebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: String::new(),
|
|
action: WebSearchAction::Other,
|
|
results: None,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::WebSearch(WebSearchItem {
|
|
id: "search-1".to_string(),
|
|
query: "rust async await".to_string(),
|
|
action: WebSearchAction::Search {
|
|
query: Some("rust async await".to_string()),
|
|
queries: None,
|
|
},
|
|
results: None,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_call_begin_and_end_emit_item_events() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::McpToolCall {
|
|
id: "mcp-1".to_string(),
|
|
server: "server_a".to_string(),
|
|
environment_id: None,
|
|
tool: "tool_x".to_string(),
|
|
status: ApiMcpToolCallStatus::InProgress,
|
|
arguments: json!({ "key": "value" }),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
},
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::McpToolCall {
|
|
id: "mcp-1".to_string(),
|
|
server: "server_a".to_string(),
|
|
environment_id: None,
|
|
tool: "tool_x".to_string(),
|
|
status: ApiMcpToolCallStatus::Completed,
|
|
arguments: json!({ "key": "value" }),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: Some(Box::new(McpToolCallResult {
|
|
content: Vec::new(),
|
|
structured_content: None,
|
|
meta: None,
|
|
})),
|
|
error: None,
|
|
duration_ms: Some(1_000),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
|
server: "server_a".to_string(),
|
|
tool: "tool_x".to_string(),
|
|
arguments: json!({ "key": "value" }),
|
|
result: None,
|
|
error: None,
|
|
status: McpToolCallStatus::InProgress,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
|
server: "server_a".to_string(),
|
|
tool: "tool_x".to_string(),
|
|
arguments: json!({ "key": "value" }),
|
|
result: Some(McpToolCallItemResult {
|
|
content: Vec::new(),
|
|
meta: None,
|
|
structured_content: None,
|
|
}),
|
|
error: None,
|
|
status: McpToolCallStatus::Completed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_call_failure_sets_failed_status() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::McpToolCall {
|
|
id: "mcp-2".to_string(),
|
|
server: "server_b".to_string(),
|
|
environment_id: None,
|
|
tool: "tool_y".to_string(),
|
|
status: ApiMcpToolCallStatus::Failed,
|
|
arguments: json!({ "param": 42 }),
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: None,
|
|
error: Some(McpToolCallError {
|
|
message: "tool exploded".to_string(),
|
|
}),
|
|
duration_ms: Some(5),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
|
server: "server_b".to_string(),
|
|
tool: "tool_y".to_string(),
|
|
arguments: json!({ "param": 42 }),
|
|
result: None,
|
|
error: Some(McpToolCallItemError {
|
|
message: "tool exploded".to_string(),
|
|
}),
|
|
status: McpToolCallStatus::Failed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::McpToolCall {
|
|
id: "mcp-3".to_string(),
|
|
server: "server_c".to_string(),
|
|
environment_id: None,
|
|
tool: "tool_z".to_string(),
|
|
status: ApiMcpToolCallStatus::InProgress,
|
|
arguments: serde_json::Value::Null,
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: None,
|
|
error: None,
|
|
duration_ms: None,
|
|
},
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::McpToolCall {
|
|
id: "mcp-3".to_string(),
|
|
server: "server_c".to_string(),
|
|
environment_id: None,
|
|
tool: "tool_z".to_string(),
|
|
status: ApiMcpToolCallStatus::Completed,
|
|
arguments: serde_json::Value::Null,
|
|
app_context: None,
|
|
mcp_app_resource_uri: None,
|
|
mcp_app_ui: None,
|
|
plugin_id: None,
|
|
read_only_hint: None,
|
|
result: Some(Box::new(McpToolCallResult {
|
|
content: vec![json!({
|
|
"type": "text",
|
|
"text": "done",
|
|
})],
|
|
structured_content: Some(json!({ "status": "ok" })),
|
|
meta: None,
|
|
})),
|
|
error: None,
|
|
duration_ms: Some(10),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
|
server: "server_c".to_string(),
|
|
tool: "tool_z".to_string(),
|
|
arguments: serde_json::Value::Null,
|
|
result: None,
|
|
error: None,
|
|
status: McpToolCallStatus::InProgress,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::McpToolCall(McpToolCallItem {
|
|
server: "server_c".to_string(),
|
|
tool: "tool_z".to_string(),
|
|
arguments: serde_json::Value::Null,
|
|
result: Some(McpToolCallItemResult {
|
|
content: vec![json!({
|
|
"type": "text",
|
|
"text": "done",
|
|
})],
|
|
meta: None,
|
|
structured_content: Some(json!({ "status": "ok" })),
|
|
}),
|
|
error: None,
|
|
status: McpToolCallStatus::Completed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn collab_spawn_begin_and_end_emit_item_events() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-parent".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::CollabAgentToolCall {
|
|
id: "collab-1".to_string(),
|
|
tool: CollabAgentTool::SpawnAgent,
|
|
status: ApiCollabAgentToolCallStatus::InProgress,
|
|
sender_thread_id: "thread-parent".to_string(),
|
|
receiver_thread_ids: Vec::new(),
|
|
prompt: Some("draft a plan".to_string()),
|
|
model: Some("gpt-5".to_string()),
|
|
reasoning_effort: None,
|
|
agents_states: std::collections::HashMap::new(),
|
|
},
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-parent".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::CollabAgentToolCall {
|
|
id: "collab-1".to_string(),
|
|
tool: CollabAgentTool::SpawnAgent,
|
|
status: ApiCollabAgentToolCallStatus::Completed,
|
|
sender_thread_id: "thread-parent".to_string(),
|
|
receiver_thread_ids: vec!["thread-child".to_string()],
|
|
prompt: Some("draft a plan".to_string()),
|
|
model: Some("gpt-5".to_string()),
|
|
reasoning_effort: None,
|
|
agents_states: std::collections::HashMap::from([(
|
|
"thread-child".to_string(),
|
|
ApiCollabAgentState {
|
|
status: ApiCollabAgentStatus::Running,
|
|
message: None,
|
|
},
|
|
)]),
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
|
|
tool: CollabTool::SpawnAgent,
|
|
sender_thread_id: "thread-parent".to_string(),
|
|
receiver_thread_ids: Vec::new(),
|
|
prompt: Some("draft a plan".to_string()),
|
|
agents_states: std::collections::HashMap::new(),
|
|
status: CollabToolCallStatus::InProgress,
|
|
},),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CollabToolCall(CollabToolCallItem {
|
|
tool: CollabTool::SpawnAgent,
|
|
sender_thread_id: "thread-parent".to_string(),
|
|
receiver_thread_ids: vec!["thread-child".to_string()],
|
|
prompt: Some("draft a plan".to_string()),
|
|
agents_states: std::collections::HashMap::from([(
|
|
"thread-child".to_string(),
|
|
CollabAgentState {
|
|
status: CollabAgentStatus::Running,
|
|
message: None,
|
|
},
|
|
)]),
|
|
status: CollabToolCallStatus::Completed,
|
|
},),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_change_completion_maps_change_kinds() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::FileChange {
|
|
id: "patch-1".to_string(),
|
|
changes: vec![
|
|
ApiFileUpdateChange {
|
|
path: "a/added.txt".to_string(),
|
|
kind: ApiPatchChangeKind::Add,
|
|
diff: String::new(),
|
|
},
|
|
ApiFileUpdateChange {
|
|
path: "b/deleted.txt".to_string(),
|
|
kind: ApiPatchChangeKind::Delete,
|
|
diff: String::new(),
|
|
},
|
|
ApiFileUpdateChange {
|
|
path: "c/modified.txt".to_string(),
|
|
kind: ApiPatchChangeKind::Update { move_path: None },
|
|
diff: "@@ -1 +1 @@".to_string(),
|
|
},
|
|
],
|
|
status: ApiPatchApplyStatus::Completed,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::FileChange(FileChangeItem {
|
|
changes: vec![
|
|
ExecFileUpdateChange {
|
|
path: "a/added.txt".to_string(),
|
|
kind: PatchChangeKind::Add,
|
|
},
|
|
ExecFileUpdateChange {
|
|
path: "b/deleted.txt".to_string(),
|
|
kind: PatchChangeKind::Delete,
|
|
},
|
|
ExecFileUpdateChange {
|
|
path: "c/modified.txt".to_string(),
|
|
kind: PatchChangeKind::Update,
|
|
},
|
|
],
|
|
status: PatchApplyStatus::Completed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn file_change_declined_maps_to_failed_status() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::FileChange {
|
|
id: "patch-2".to_string(),
|
|
changes: vec![ApiFileUpdateChange {
|
|
path: "file.txt".to_string(),
|
|
kind: ApiPatchChangeKind::Update { move_path: None },
|
|
diff: "@@ -1 +1 @@".to_string(),
|
|
}],
|
|
status: ApiPatchApplyStatus::Declined,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::FileChange(FileChangeItem {
|
|
changes: vec![ExecFileUpdateChange {
|
|
path: "file.txt".to_string(),
|
|
kind: PatchChangeKind::Update,
|
|
}],
|
|
status: PatchApplyStatus::Failed,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn agent_message_item_updates_final_message() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-1".to_string(),
|
|
text: "hello".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
|
|
text: "hello".to_string(),
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
assert_eq!(processor.final_message(), Some("hello"));
|
|
}
|
|
|
|
#[test]
|
|
fn agent_message_item_started_is_ignored() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-1".to_string(),
|
|
text: "hello".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: Vec::new(),
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reasoning_item_completed_uses_synthetic_id() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::Reasoning {
|
|
id: "rs-1".to_string(),
|
|
summary: vec!["thinking...".to_string()],
|
|
content: vec!["raw".to_string()],
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::Reasoning(ReasoningItem {
|
|
text: "thinking...".to_string(),
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn warning_event_produces_error_item() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_warning(
|
|
"Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
|
|
);
|
|
|
|
assert_eq!(
|
|
collected,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::Error(ErrorItem {
|
|
message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start a new conversation when possible to keep conversations small and targeted.".to_string(),
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_update_emits_started_then_updated_then_completed() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::TurnPlanUpdated(
|
|
TurnPlanUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
explanation: None,
|
|
plan: vec![
|
|
TurnPlanStep {
|
|
step: "step one".to_string(),
|
|
status: TurnPlanStepStatus::Pending,
|
|
},
|
|
TurnPlanStep {
|
|
step: "step two".to_string(),
|
|
status: TurnPlanStepStatus::InProgress,
|
|
},
|
|
],
|
|
},
|
|
));
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::TodoList(TodoListItem {
|
|
items: vec![
|
|
TodoItem {
|
|
text: "step one".to_string(),
|
|
completed: false,
|
|
},
|
|
TodoItem {
|
|
text: "step two".to_string(),
|
|
completed: false,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let updated = processor.collect_thread_events(ServerNotification::TurnPlanUpdated(
|
|
TurnPlanUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
explanation: None,
|
|
plan: vec![
|
|
TurnPlanStep {
|
|
step: "step one".to_string(),
|
|
status: TurnPlanStepStatus::Completed,
|
|
},
|
|
TurnPlanStep {
|
|
step: "step two".to_string(),
|
|
status: TurnPlanStepStatus::InProgress,
|
|
},
|
|
],
|
|
},
|
|
));
|
|
assert_eq!(
|
|
updated,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemUpdated(ItemUpdatedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::TodoList(TodoListItem {
|
|
items: vec![
|
|
TodoItem {
|
|
text: "step one".to_string(),
|
|
completed: true,
|
|
},
|
|
TodoItem {
|
|
text: "step two".to_string(),
|
|
completed: false,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![
|
|
ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::TodoList(TodoListItem {
|
|
items: vec![
|
|
TodoItem {
|
|
text: "step one".to_string(),
|
|
completed: true,
|
|
},
|
|
TodoItem {
|
|
text: "step two".to_string(),
|
|
completed: false,
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
}),
|
|
ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
}),
|
|
],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_update_after_completion_starts_new_todo_list_with_new_id() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let _ = processor.collect_thread_events(ServerNotification::TurnPlanUpdated(
|
|
TurnPlanUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
explanation: None,
|
|
plan: vec![TurnPlanStep {
|
|
step: "only".to_string(),
|
|
status: TurnPlanStepStatus::Pending,
|
|
}],
|
|
},
|
|
));
|
|
let _ = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
let restarted = processor.collect_thread_events(ServerNotification::TurnPlanUpdated(
|
|
TurnPlanUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-2".to_string(),
|
|
explanation: None,
|
|
plan: vec![TurnPlanStep {
|
|
step: "again".to_string(),
|
|
status: TurnPlanStepStatus::Pending,
|
|
}],
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
restarted,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_1".to_string(),
|
|
details: ThreadItemDetails::TodoList(TodoListItem {
|
|
items: vec![TodoItem {
|
|
text: "again".to_string(),
|
|
completed: false,
|
|
}],
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn token_usage_update_is_emitted_on_turn_completion() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let usage_update =
|
|
processor.collect_thread_events(ServerNotification::ThreadTokenUsageUpdated(
|
|
codex_app_server_protocol::ThreadTokenUsageUpdatedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
token_usage: ThreadTokenUsage {
|
|
total: TokenUsageBreakdown {
|
|
total_tokens: 42,
|
|
input_tokens: 10,
|
|
cached_input_tokens: 3,
|
|
cache_write_input_tokens: 4,
|
|
output_tokens: 29,
|
|
reasoning_output_tokens: 7,
|
|
},
|
|
last: TokenUsageBreakdown {
|
|
total_tokens: 42,
|
|
input_tokens: 10,
|
|
cached_input_tokens: 3,
|
|
cache_write_input_tokens: 4,
|
|
output_tokens: 29,
|
|
reasoning_output_tokens: 7,
|
|
},
|
|
model_context_window: Some(128_000),
|
|
},
|
|
},
|
|
));
|
|
assert_eq!(
|
|
usage_update,
|
|
CollectedThreadEvents {
|
|
events: Vec::new(),
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage {
|
|
input_tokens: 10,
|
|
cached_input_tokens: 3,
|
|
cache_write_input_tokens: 4,
|
|
output_tokens: 29,
|
|
reasoning_output_tokens: 7,
|
|
},
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_completion_recovers_final_message_from_turn_items() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-1".to_string(),
|
|
text: "final answer".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
})],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
assert_eq!(processor.final_message(), Some("final answer"));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_completion_reconciles_started_items_from_turn_items() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
|
|
ItemStartedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::CommandExecution {
|
|
model_context: None,
|
|
sandbox_type: None,
|
|
id: "cmd-1".to_string(),
|
|
command: "ls".to_string(),
|
|
cwd: test_path_buf("/tmp/project").abs().into(),
|
|
process_id: Some("123".to_string()),
|
|
plugin_id: None,
|
|
script_path: None,
|
|
source: CommandExecutionSource::UserShell,
|
|
status: ApiCommandExecutionStatus::InProgress,
|
|
command_actions: Vec::<CommandAction>::new(),
|
|
aggregated_output: None,
|
|
exit_code: None,
|
|
duration_ms: None,
|
|
},
|
|
/*started_at_ms*/ 0,
|
|
),
|
|
));
|
|
assert_eq!(
|
|
started,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::ItemStarted(ItemStartedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
|
command: "ls".to_string(),
|
|
aggregated_output: String::new(),
|
|
exit_code: None,
|
|
status: CommandExecutionStatus::InProgress,
|
|
}),
|
|
},
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![ThreadItem::untimed(ThreadItemPayload::CommandExecution {
|
|
model_context: None,
|
|
sandbox_type: None,
|
|
id: "cmd-1".to_string(),
|
|
command: "ls".to_string(),
|
|
cwd: test_path_buf("/tmp/project").abs().into(),
|
|
process_id: Some("123".to_string()),
|
|
plugin_id: None,
|
|
script_path: None,
|
|
source: CommandExecutionSource::UserShell,
|
|
status: ApiCommandExecutionStatus::Completed,
|
|
command_actions: Vec::<CommandAction>::new(),
|
|
aggregated_output: Some("a.txt\n".to_string()),
|
|
exit_code: Some(0),
|
|
duration_ms: Some(3),
|
|
})],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![
|
|
ThreadEvent::ItemCompleted(ItemCompletedEvent {
|
|
item: ExecThreadItem {
|
|
id: "item_0".to_string(),
|
|
details: ThreadItemDetails::CommandExecution(CommandExecutionItem {
|
|
command: "ls".to_string(),
|
|
aggregated_output: "a.txt\n".to_string(),
|
|
exit_code: Some(0),
|
|
status: CommandExecutionStatus::Completed,
|
|
}),
|
|
},
|
|
}),
|
|
ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
}),
|
|
],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_completion_overwrites_stale_final_message_from_turn_items() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
let _ = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-stale".to_string(),
|
|
text: "stale answer".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-1".to_string(),
|
|
text: "final answer".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
})],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
assert_eq!(processor.final_message(), Some("final answer"));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
let _ = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-streamed".to_string(),
|
|
text: "streamed answer".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
assert_eq!(processor.final_message(), Some("streamed answer"));
|
|
}
|
|
|
|
#[test]
|
|
fn failed_turn_clears_stale_final_message() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
|
|
ItemCompletedNotification::new(
|
|
"thread-1".to_string(),
|
|
"turn-1".to_string(),
|
|
ThreadItemPayload::AgentMessage {
|
|
moderation: None,
|
|
id: "msg-1".to_string(),
|
|
text: "partial answer".to_string(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
delivery: None,
|
|
questions: None,
|
|
},
|
|
/*started_at_ms*/ None,
|
|
/*completed_at_ms*/ 0,
|
|
),
|
|
));
|
|
|
|
assert_eq!(collected.status, CodexStatus::Running);
|
|
assert_eq!(processor.final_message(), Some("partial answer"));
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Failed,
|
|
error: Some(TurnError {
|
|
misalignment: None,
|
|
message: "turn failed".to_string(),
|
|
additional_details: None,
|
|
codex_error_info: None,
|
|
}),
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(collected.status, CodexStatus::InitiateShutdown);
|
|
assert_eq!(processor.final_message(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_completion_falls_back_to_final_plan_text() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: vec![ThreadItem::untimed(ThreadItemPayload::Plan {
|
|
id: "plan-1".to_string(),
|
|
text: "ship the typed adapter".to_string(),
|
|
})],
|
|
status: TurnStatus::Completed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
|
|
assert_eq!(
|
|
completed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnCompleted(TurnCompletedEvent {
|
|
usage: Usage::default(),
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
assert_eq!(processor.final_message(), Some("ship the typed adapter"));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_failure_prefers_structured_error_message() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let error = processor.collect_thread_events(ServerNotification::Error(ErrorNotification {
|
|
error: TurnError {
|
|
misalignment: None,
|
|
message: "backend failed".to_string(),
|
|
codex_error_info: None,
|
|
additional_details: Some("request id abc".to_string()),
|
|
},
|
|
will_retry: false,
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
}));
|
|
assert_eq!(
|
|
error,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::Error(ThreadErrorEvent {
|
|
message: "backend failed (request id abc)".to_string(),
|
|
})],
|
|
status: CodexStatus::Running,
|
|
}
|
|
);
|
|
|
|
let failed = processor.collect_thread_events(ServerNotification::TurnCompleted(
|
|
TurnCompletedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn: Turn {
|
|
id: "turn-1".to_string(),
|
|
root_turn_id: None,
|
|
items_view: codex_app_server_protocol::TurnItemsView::Full,
|
|
items: Vec::new(),
|
|
status: TurnStatus::Failed,
|
|
error: None,
|
|
started_at: None,
|
|
completed_at: None,
|
|
duration_ms: None,
|
|
},
|
|
},
|
|
));
|
|
assert_eq!(
|
|
failed,
|
|
CollectedThreadEvents {
|
|
events: vec![ThreadEvent::TurnFailed(TurnFailedEvent {
|
|
error: ThreadErrorEvent {
|
|
message: "backend failed (request id abc)".to_string(),
|
|
},
|
|
})],
|
|
status: CodexStatus::InitiateShutdown,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn model_reroute_surfaces_as_error_item() {
|
|
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
|
|
|
|
let collected = processor.collect_thread_events(ServerNotification::ModelRerouted(
|
|
codex_app_server_protocol::ModelReroutedNotification {
|
|
thread_id: "thread-1".to_string(),
|
|
turn_id: "turn-1".to_string(),
|
|
from_model: "gpt-5".to_string(),
|
|
to_model: "gpt-5-mini".to_string(),
|
|
reason: codex_app_server_protocol::ModelRerouteReason::HighRiskCyberActivity,
|
|
},
|
|
));
|
|
|
|
assert_eq!(collected.status, CodexStatus::Running);
|
|
assert_eq!(collected.events.len(), 1);
|
|
let ThreadEvent::ItemCompleted(ItemCompletedEvent { item }) = &collected.events[0] else {
|
|
panic!("expected ItemCompleted");
|
|
};
|
|
assert_eq!(item.id, "item_0");
|
|
assert_eq!(
|
|
item.details,
|
|
ThreadItemDetails::Error(ErrorItem {
|
|
message: "model rerouted: gpt-5 -> gpt-5-mini (HighRiskCyberActivity)".to_string(),
|
|
})
|
|
);
|
|
}
|