mirror of
https://github.com/openai/codex.git
synced 2026-04-26 15:45:02 +00:00
Motivation - Today, a newly connected client has no direct way to determine the current runtime status of threads from read/list responses alone. - This forces clients to infer state from transient events, which can lead to stale or inconsistent UI when reconnecting or attaching late. Changes - Add `status` to `thread/read` responses. - Add `statuses` to `thread/list` responses. - Emit `thread/status/changed` notifications with `threadId` and the new status. - Track runtime status for all loaded threads and default unknown threads to `idle`. - Update protocol/docs/tests/schema fixtures for the revised API. Testing - Validated protocol API changes with automated protocol tests and regenerated schema/type fixtures. - Validated app-server behavior with unit and integration test suites, including status transitions and notifications.
241 lines
8.1 KiB
Rust
241 lines
8.1 KiB
Rust
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_final_assistant_message_sse_response;
|
|
use app_test_support::create_mock_responses_server_sequence;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::ClientInfo;
|
|
use codex_app_server_protocol::InitializeCapabilities;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use codex_app_server_protocol::JSONRPCNotification;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::ThreadStartParams;
|
|
use codex_app_server_protocol::ThreadStartResponse;
|
|
use codex_app_server_protocol::ThreadStatus;
|
|
use codex_app_server_protocol::ThreadStatusChangedNotification;
|
|
use codex_app_server_protocol::TurnStartParams;
|
|
use codex_app_server_protocol::TurnStartResponse;
|
|
use codex_app_server_protocol::UserInput as V2UserInput;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
|
async fn thread_status_changed_emits_runtime_updates() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let responses = vec![create_final_assistant_message_sse_response("done")?];
|
|
let server = create_mock_responses_server_sequence(responses).await;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp =
|
|
McpProcess::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let thread_start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let thread_start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
|
|
|
let turn_start_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id.clone(),
|
|
input: vec![V2UserInput::Text {
|
|
text: "collect status updates".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
|
)
|
|
.await??;
|
|
let _: TurnStartResponse = to_response(turn_start_resp)?;
|
|
|
|
let mut saw_active_running = false;
|
|
let mut saw_idle_after_turn = false;
|
|
let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT;
|
|
while tokio::time::Instant::now() < deadline {
|
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
|
let message = match timeout(remaining, mcp.read_next_message()).await {
|
|
Ok(Ok(message)) => message,
|
|
_ => break,
|
|
};
|
|
match message {
|
|
JSONRPCMessage::Notification(JSONRPCNotification {
|
|
method,
|
|
params: Some(params),
|
|
}) if method == "thread/status/changed" => {
|
|
let notification: ThreadStatusChangedNotification = serde_json::from_value(params)?;
|
|
if notification.thread_id != thread.id {
|
|
continue;
|
|
}
|
|
match notification.status {
|
|
ThreadStatus::Active { .. } => {
|
|
saw_active_running = true;
|
|
}
|
|
ThreadStatus::Idle => {
|
|
if saw_active_running {
|
|
saw_idle_after_turn = true;
|
|
}
|
|
}
|
|
ThreadStatus::SystemError => {
|
|
if saw_active_running {
|
|
saw_idle_after_turn = true;
|
|
}
|
|
}
|
|
ThreadStatus::NotLoaded => {
|
|
if saw_active_running {
|
|
saw_idle_after_turn = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if saw_active_running && saw_idle_after_turn {
|
|
break;
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
saw_active_running,
|
|
"expected running active flag in thread/status/changed notifications"
|
|
);
|
|
assert!(
|
|
saw_idle_after_turn,
|
|
"expected idle status after turn completion in thread/status/changed notifications"
|
|
);
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn thread_status_changed_can_be_opted_out() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let responses = vec![create_final_assistant_message_sse_response("done")?];
|
|
let server = create_mock_responses_server_sequence(responses).await;
|
|
create_config_toml(codex_home.path(), &server.uri())?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
let message = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.initialize_with_capabilities(
|
|
ClientInfo {
|
|
name: "codex_vscode".to_string(),
|
|
title: Some("Codex VS Code Extension".to_string()),
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
Some(InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]),
|
|
}),
|
|
),
|
|
)
|
|
.await??;
|
|
let JSONRPCMessage::Response(_) = message else {
|
|
anyhow::bail!("expected initialize response, got {message:?}");
|
|
};
|
|
|
|
let thread_start_id = mcp
|
|
.send_thread_start_request(ThreadStartParams {
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let thread_start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
|
|
)
|
|
.await??;
|
|
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
|
|
|
|
let turn_start_id = mcp
|
|
.send_turn_start_request(TurnStartParams {
|
|
thread_id: thread.id,
|
|
input: vec![V2UserInput::Text {
|
|
text: "run once".to_string(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
model: Some("mock-model".to_string()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let turn_start_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
|
|
)
|
|
.await??;
|
|
let _: TurnStartResponse = to_response(turn_start_resp)?;
|
|
|
|
timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_notification_message("turn/completed"),
|
|
)
|
|
.await??;
|
|
|
|
let status_update = timeout(
|
|
std::time::Duration::from_millis(500),
|
|
mcp.read_stream_until_notification_message("thread/status/changed"),
|
|
)
|
|
.await;
|
|
match status_update {
|
|
Err(_) => {}
|
|
Ok(Ok(notification)) => {
|
|
anyhow::bail!(
|
|
"thread/status/changed should be filtered by optOutNotificationMethods; got: {notification:?}"
|
|
);
|
|
}
|
|
Ok(Err(err)) => {
|
|
anyhow::bail!(
|
|
"expected timeout waiting for filtered thread/status/changed, got: {err}"
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
|
|
let config_toml = codex_home.join("config.toml");
|
|
std::fs::write(
|
|
config_toml,
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "untrusted"
|
|
sandbox_mode = "read-only"
|
|
|
|
model_provider = "mock_provider"
|
|
|
|
[features]
|
|
collaboration_modes = true
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "responses"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#
|
|
),
|
|
)
|
|
}
|