We continue the separation between `codex app-server` and `codex mcp-server`. In particular, we introduce a new crate, `codex-app-server-protocol`, and migrate `codex-rs/protocol/src/mcp_protocol.rs` into it, renaming it `codex-rs/app-server-protocol/src/protocol.rs`. Because `ConversationId` was defined in `mcp_protocol.rs`, we move it into its own file, `codex-rs/protocol/src/conversation_id.rs`, and because it is referenced in a ton of places, we have to touch a lot of files as part of this PR. We also decide to get away from proper JSON-RPC 2.0 semantics, so we also introduce `codex-rs/app-server-protocol/src/jsonrpc_lite.rs`, which is basically the same `JSONRPCMessage` type defined in `mcp-types` except with all of the `"jsonrpc": "2.0"` removed. Getting rid of `"jsonrpc": "2.0"` makes our serialization logic considerably simpler, as we can lean heavier on serde to serialize directly into the wire format that we use now.
160 lines
5.8 KiB
Rust
160 lines
5.8 KiB
Rust
#![cfg(unix)]
|
|
// Support code lives in the `app_test_support` crate under tests/common.
|
|
|
|
use std::path::Path;
|
|
|
|
use codex_app_server_protocol::AddConversationListenerParams;
|
|
use codex_app_server_protocol::InterruptConversationParams;
|
|
use codex_app_server_protocol::InterruptConversationResponse;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::NewConversationParams;
|
|
use codex_app_server_protocol::NewConversationResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_app_server_protocol::SendUserMessageParams;
|
|
use codex_app_server_protocol::SendUserMessageResponse;
|
|
use codex_core::protocol::TurnAbortReason;
|
|
use core_test_support::skip_if_no_network;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::create_mock_chat_completions_server;
|
|
use app_test_support::create_shell_sse_response;
|
|
use app_test_support::to_response;
|
|
|
|
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn test_shell_command_interruption() {
|
|
skip_if_no_network!();
|
|
|
|
if let Err(err) = shell_command_interruption().await {
|
|
panic!("failure: {err}");
|
|
}
|
|
}
|
|
|
|
async fn shell_command_interruption() -> anyhow::Result<()> {
|
|
// Use a cross-platform blocking command. On Windows plain `sleep` is not guaranteed to exist
|
|
// (MSYS/GNU coreutils may be absent) and the failure causes the tool call to finish immediately,
|
|
// which triggers a second model request before the test sends the explicit follow-up. That
|
|
// prematurely consumes the second mocked SSE response and leads to a third POST (panic: no response for 2).
|
|
// Powershell Start-Sleep is always available on Windows runners. On Unix we keep using `sleep`.
|
|
#[cfg(target_os = "windows")]
|
|
let shell_command = vec![
|
|
"powershell".to_string(),
|
|
"-Command".to_string(),
|
|
"Start-Sleep -Seconds 10".to_string(),
|
|
];
|
|
#[cfg(not(target_os = "windows"))]
|
|
let shell_command = vec!["sleep".to_string(), "10".to_string()];
|
|
|
|
let tmp = TempDir::new()?;
|
|
// Temporary Codex home with config pointing at the mock server.
|
|
let codex_home = tmp.path().join("codex_home");
|
|
std::fs::create_dir(&codex_home)?;
|
|
let working_directory = tmp.path().join("workdir");
|
|
std::fs::create_dir(&working_directory)?;
|
|
|
|
// Create mock server with a single SSE response: the long sleep command
|
|
let server = create_mock_chat_completions_server(vec![create_shell_sse_response(
|
|
shell_command.clone(),
|
|
Some(&working_directory),
|
|
Some(10_000), // 10 seconds timeout in ms
|
|
"call_sleep",
|
|
)?])
|
|
.await;
|
|
create_config_toml(&codex_home, server.uri())?;
|
|
|
|
// Start MCP server and initialize.
|
|
let mut mcp = McpProcess::new(&codex_home).await?;
|
|
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
// 1) newConversation
|
|
let new_conv_id = mcp
|
|
.send_new_conversation_request(NewConversationParams {
|
|
cwd: Some(working_directory.to_string_lossy().into_owned()),
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let new_conv_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)),
|
|
)
|
|
.await??;
|
|
let new_conv_resp = to_response::<NewConversationResponse>(new_conv_resp)?;
|
|
let NewConversationResponse {
|
|
conversation_id, ..
|
|
} = new_conv_resp;
|
|
|
|
// 2) addConversationListener
|
|
let add_listener_id = mcp
|
|
.send_add_conversation_listener_request(AddConversationListenerParams { conversation_id })
|
|
.await?;
|
|
let _add_listener_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
|
|
)
|
|
.await??;
|
|
|
|
// 3) sendUserMessage (should trigger notifications; we only validate an OK response)
|
|
let send_user_id = mcp
|
|
.send_send_user_message_request(SendUserMessageParams {
|
|
conversation_id,
|
|
items: vec![codex_app_server_protocol::InputItem::Text {
|
|
text: "run first sleep command".to_string(),
|
|
}],
|
|
})
|
|
.await?;
|
|
let send_user_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)),
|
|
)
|
|
.await??;
|
|
let SendUserMessageResponse {} = to_response::<SendUserMessageResponse>(send_user_resp)?;
|
|
|
|
// Give the command a moment to start
|
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
|
|
// 4) send interrupt request
|
|
let interrupt_id = mcp
|
|
.send_interrupt_conversation_request(InterruptConversationParams { conversation_id })
|
|
.await?;
|
|
let interrupt_resp: JSONRPCResponse = timeout(
|
|
DEFAULT_READ_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)),
|
|
)
|
|
.await??;
|
|
let InterruptConversationResponse { abort_reason } =
|
|
to_response::<InterruptConversationResponse>(interrupt_resp)?;
|
|
assert_eq!(TurnAbortReason::Interrupted, abort_reason);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> {
|
|
let config_toml = codex_home.join("config.toml");
|
|
std::fs::write(
|
|
config_toml,
|
|
format!(
|
|
r#"
|
|
model = "mock-model"
|
|
approval_policy = "never"
|
|
sandbox_mode = "danger-full-access"
|
|
|
|
model_provider = "mock_provider"
|
|
|
|
[model_providers.mock_provider]
|
|
name = "Mock provider for test"
|
|
base_url = "{server_uri}/v1"
|
|
wire_api = "chat"
|
|
request_max_retries = 0
|
|
stream_max_retries = 0
|
|
"#
|
|
),
|
|
)
|
|
}
|