diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 793970979c..269dfcf6b5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -171,8 +171,7 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "codex-protocol", - "mcp-types", + "codex-app-server-protocol", "serde", "serde_json", "tokio", @@ -657,6 +656,7 @@ dependencies = [ "app_test_support", "assert_cmd", "base64", + "codex-app-server-protocol", "codex-arg0", "codex-common", "codex-core", @@ -665,7 +665,6 @@ dependencies = [ "codex-protocol", "codex-utils-json-to-toml", "core_test_support", - "mcp-types", "os_info", "pretty_assertions", "serde", @@ -679,6 +678,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-app-server-protocol" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "paste", + "pretty_assertions", + "serde", + "serde_json", + "strum_macros 0.27.2", + "ts-rs", + "uuid", +] + [[package]] name = "codex-apply-patch" version = "0.0.0" @@ -750,6 +764,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-protocol", "codex-arg0", "codex-chatgpt", "codex-cloud-tasks", @@ -771,8 +786,6 @@ dependencies = [ "supports-color", "tempfile", "tokio", - "tracing", - "tracing-subscriber", ] [[package]] @@ -822,6 +835,7 @@ name = "codex-common" version = "0.0.0" dependencies = [ "clap", + "codex-app-server-protocol", "codex-core", "codex-protocol", "serde", @@ -840,6 +854,7 @@ dependencies = [ "base64", "bytes", "chrono", + "codex-app-server-protocol", "codex-apply-patch", "codex-file-search", "codex-mcp-client", @@ -997,8 +1012,8 @@ dependencies = [ "anyhow", "base64", "chrono", + "codex-app-server-protocol", "codex-core", - "codex-protocol", "core_test_support", "rand 0.9.2", "reqwest", @@ -1074,6 +1089,7 @@ name = "codex-otel" version = "0.0.0" dependencies = [ "chrono", + "codex-app-server-protocol", "codex-protocol", "eventsource-stream", "opentelemetry", @@ -1106,7 +1122,6 @@ dependencies = [ "icu_locale_core", "mcp-types", "mime_guess", - "pretty_assertions", "serde", "serde_json", "serde_with", @@ -1125,8 +1140,7 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", - "codex-protocol", - "mcp-types", + "codex-app-server-protocol", "ts-rs", ] @@ -1174,6 +1188,7 @@ dependencies = [ "chrono", "clap", "codex-ansi-escape", + "codex-app-server-protocol", "codex-arg0", "codex-common", "codex-core", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 783ef097c1..05e6cd02c2 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -3,6 +3,7 @@ members = [ "backend-client", "ansi-escape", "app-server", + "app-server-protocol", "apply-patch", "arg0", "codex-backend-openapi-models", @@ -48,6 +49,7 @@ edition = "2024" app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } codex-app-server = { path = "app-server" } +codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-chatgpt = { path = "chatgpt" } @@ -125,6 +127,7 @@ opentelemetry-semantic-conventions = "0.30.0" opentelemetry_sdk = "0.30.0" os_info = "3.12.0" owo-colors = "4.2.0" +paste = "1.0.15" path-absolutize = "3.1.1" path-clean = "1.0.1" pathdiff = "0.2" diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml new file mode 100644 index 0000000000..b18028fbe4 --- /dev/null +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -0,0 +1,24 @@ +[package] +edition = "2024" +name = "codex-app-server-protocol" +version = { workspace = true } + +[lib] +name = "codex_app_server_protocol" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +paste = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +strum_macros = { workspace = true } +ts-rs = { workspace = true } +uuid = { workspace = true, features = ["serde", "v7"] } + +[dev-dependencies] +anyhow = { workspace = true } +pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs new file mode 100644 index 0000000000..e458125dd2 --- /dev/null +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -0,0 +1,66 @@ +//! We do not do true JSON-RPC 2.0, as we neither send nor expect the +//! "jsonrpc": "2.0" field. + +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +pub const JSONRPC_VERSION: &str = "2.0"; + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[serde(untagged)] +pub enum RequestId { + String(String), + Integer(i64), +} + +pub type Result = serde_json::Value; + +/// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[serde(untagged)] +pub enum JSONRPCMessage { + Request(JSONRPCRequest), + Notification(JSONRPCNotification), + Response(JSONRPCResponse), + Error(JSONRPCError), +} + +/// A request that expects a response. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +pub struct JSONRPCRequest { + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// A notification which does not expect a response. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +pub struct JSONRPCNotification { + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// A successful (non-error) response to a request. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +pub struct JSONRPCResponse { + pub id: RequestId, + pub result: Result, +} + +/// A response to a request that indicates an error occurred. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +pub struct JSONRPCError { + pub error: JSONRPCErrorError, + pub id: RequestId, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +pub struct JSONRPCErrorError { + pub code: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + pub message: String, +} diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs new file mode 100644 index 0000000000..517702bbd8 --- /dev/null +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -0,0 +1,5 @@ +mod jsonrpc_lite; +mod protocol; + +pub use jsonrpc_lite::*; +pub use protocol::*; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs similarity index 78% rename from codex-rs/protocol/src/mcp_protocol.rs rename to codex-rs/app-server-protocol/src/protocol.rs index 107055f6e0..4bc10b2e65 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -1,77 +1,27 @@ use std::collections::HashMap; -use std::fmt::Display; use std::path::PathBuf; -use crate::config_types::ReasoningEffort; -use crate::config_types::ReasoningSummary; -use crate::config_types::SandboxMode; -use crate::config_types::Verbosity; -use crate::protocol::AskForApproval; -use crate::protocol::EventMsg; -use crate::protocol::FileChange; -use crate::protocol::ReviewDecision; -use crate::protocol::SandboxPolicy; -use crate::protocol::TurnAbortReason; -use mcp_types::JSONRPCNotification; -use mcp_types::RequestId; +use crate::JSONRPCNotification; +use crate::JSONRPCRequest; +use crate::RequestId; +use codex_protocol::ConversationId; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::Verbosity; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnAbortReason; +use paste::paste; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; -#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] -#[ts(type = "string")] -pub struct ConversationId { - uuid: Uuid, -} - -impl ConversationId { - pub fn new() -> Self { - Self { - uuid: Uuid::now_v7(), - } - } - - pub fn from_string(s: &str) -> Result { - Ok(Self { - uuid: Uuid::parse_str(s)?, - }) - } -} - -impl Default for ConversationId { - fn default() -> Self { - Self::new() - } -} - -impl Display for ConversationId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.uuid) - } -} - -impl Serialize for ConversationId { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.collect_str(&self.uuid) - } -} - -impl<'de> Deserialize<'de> for ConversationId { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let value = String::deserialize(deserializer)?; - let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; - Ok(Self { uuid }) - } -} - #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] #[ts(type = "string")] pub struct GitSha(pub String); @@ -89,117 +39,137 @@ pub enum AuthMode { ChatGPT, } -/// Request from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(tag = "method", rename_all = "camelCase")] -pub enum ClientRequest { +/// Generates an `enum ClientRequest` where each variant is a request that the +/// client can send to the server. Each variant has associated `params` and +/// `response` types. Also generates a `export_client_responses()` function to +/// export all response types to TypeScript. +macro_rules! client_request_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident { + params: $(#[$params_meta:meta])* $params:ty, + response: $response:ty, + } + ),* $(,)? + ) => { + /// Request from the client to the server. + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ClientRequest { + $( + $(#[$variant_meta])* + $variant { + #[serde(rename = "id")] + request_id: RequestId, + $(#[$params_meta])* + params: $params, + }, + )* + } + + pub fn export_client_responses( + out_dir: &::std::path::Path, + ) -> ::std::result::Result<(), ::ts_rs::ExportError> { + $( + <$response as ::ts_rs::TS>::export_all_to(out_dir)?; + )* + Ok(()) + } + }; +} + +client_request_definitions! { Initialize { - #[serde(rename = "id")] - request_id: RequestId, params: InitializeParams, + response: InitializeResponse, }, NewConversation { - #[serde(rename = "id")] - request_id: RequestId, params: NewConversationParams, + response: NewConversationResponse, }, /// List recorded Codex conversations (rollouts) with optional pagination and search. ListConversations { - #[serde(rename = "id")] - request_id: RequestId, params: ListConversationsParams, + response: ListConversationsResponse, }, /// Resume a recorded Codex conversation from a rollout file. ResumeConversation { - #[serde(rename = "id")] - request_id: RequestId, params: ResumeConversationParams, + response: ResumeConversationResponse, }, ArchiveConversation { - #[serde(rename = "id")] - request_id: RequestId, params: ArchiveConversationParams, + response: ArchiveConversationResponse, }, SendUserMessage { - #[serde(rename = "id")] - request_id: RequestId, params: SendUserMessageParams, + response: SendUserMessageResponse, }, SendUserTurn { - #[serde(rename = "id")] - request_id: RequestId, params: SendUserTurnParams, + response: SendUserTurnResponse, }, InterruptConversation { - #[serde(rename = "id")] - request_id: RequestId, params: InterruptConversationParams, + response: InterruptConversationResponse, }, AddConversationListener { - #[serde(rename = "id")] - request_id: RequestId, params: AddConversationListenerParams, + response: AddConversationSubscriptionResponse, }, RemoveConversationListener { - #[serde(rename = "id")] - request_id: RequestId, params: RemoveConversationListenerParams, + response: RemoveConversationSubscriptionResponse, }, GitDiffToRemote { - #[serde(rename = "id")] - request_id: RequestId, params: GitDiffToRemoteParams, + response: GitDiffToRemoteResponse, }, LoginApiKey { - #[serde(rename = "id")] - request_id: RequestId, params: LoginApiKeyParams, + response: LoginApiKeyResponse, }, LoginChatGpt { - #[serde(rename = "id")] - request_id: RequestId, + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: LoginChatGptResponse, }, CancelLoginChatGpt { - #[serde(rename = "id")] - request_id: RequestId, params: CancelLoginChatGptParams, + response: CancelLoginChatGptResponse, }, LogoutChatGpt { - #[serde(rename = "id")] - request_id: RequestId, + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: LogoutChatGptResponse, }, GetAuthStatus { - #[serde(rename = "id")] - request_id: RequestId, params: GetAuthStatusParams, + response: GetAuthStatusResponse, }, GetUserSavedConfig { - #[serde(rename = "id")] - request_id: RequestId, + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: GetUserSavedConfigResponse, }, SetDefaultModel { - #[serde(rename = "id")] - request_id: RequestId, params: SetDefaultModelParams, + response: SetDefaultModelResponse, }, GetUserAgent { - #[serde(rename = "id")] - request_id: RequestId, + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: GetUserAgentResponse, }, UserInfo { - #[serde(rename = "id")] - request_id: RequestId, + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: UserInfoResponse, }, FuzzyFileSearch { - #[serde(rename = "id")] - request_id: RequestId, params: FuzzyFileSearchParams, + response: FuzzyFileSearchResponse, }, /// Execute a command (argv vector) under the server's sandbox. ExecOneOffCommand { - #[serde(rename = "id")] - request_id: RequestId, params: ExecOneOffCommandParams, + response: ExecOneOffCommandResponse, }, } @@ -429,7 +399,7 @@ pub struct ExecOneOffCommandParams { #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] -pub struct ExecArbitraryCommandResponse { +pub struct ExecOneOffCommandResponse { pub exit_code: i32, pub stdout: String, pub stderr: String, @@ -633,30 +603,74 @@ pub enum InputItem { }, } -// TODO(mbolin): Need test to ensure these constants match the enum variants. +/// Generates an `enum ServerRequest` where each variant is a request that the +/// server can send to the client along with the corresponding params and +/// response types. It also generates helper types used by the app/server +/// infrastructure (payload enum, request constructor, and export helpers). +macro_rules! server_request_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident + ),* $(,)? + ) => { + paste! { + /// Request initiated from the server and sent to the client. + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ServerRequest { + $( + $(#[$variant_meta])* + $variant { + #[serde(rename = "id")] + request_id: RequestId, + params: [<$variant Params>], + }, + )* + } -pub const APPLY_PATCH_APPROVAL_METHOD: &str = "applyPatchApproval"; -pub const EXEC_COMMAND_APPROVAL_METHOD: &str = "execCommandApproval"; + #[derive(Debug, Clone, PartialEq)] + pub enum ServerRequestPayload { + $( $variant([<$variant Params>]), )* + } -/// Request initiated from the server and sent to the client. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(tag = "method", rename_all = "camelCase")] -pub enum ServerRequest { + impl ServerRequestPayload { + pub fn request_with_id(self, request_id: RequestId) -> ServerRequest { + match self { + $(Self::$variant(params) => ServerRequest::$variant { request_id, params },)* + } + } + } + } + + pub fn export_server_responses( + out_dir: &::std::path::Path, + ) -> ::std::result::Result<(), ::ts_rs::ExportError> { + paste! { + $(<[<$variant Response>] as ::ts_rs::TS>::export_all_to(out_dir)?;)* + } + Ok(()) + } + }; +} + +impl TryFrom for ServerRequest { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCRequest) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } +} + +server_request_definitions! { /// Request to approve a patch. - ApplyPatchApproval { - #[serde(rename = "id")] - request_id: RequestId, - params: ApplyPatchApprovalParams, - }, + ApplyPatchApproval, /// Request to exec a command. - ExecCommandApproval { - #[serde(rename = "id")] - request_id: RequestId, - params: ExecCommandApprovalParams, - }, + ExecCommandApproval, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] pub struct ApplyPatchApprovalParams { pub conversation_id: ConversationId, /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] @@ -673,6 +687,7 @@ pub struct ApplyPatchApprovalParams { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] pub struct ExecCommandApprovalParams { pub conversation_id: ConversationId, /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] @@ -746,6 +761,7 @@ pub struct SessionConfiguredNotification { pub history_log_id: u64, /// Current number of entries in the history log. + #[ts(type = "number")] pub history_entry_count: usize, /// Optional initial messages (as events) for resumed sessions. @@ -842,12 +858,6 @@ mod tests { Ok(()) } - #[test] - fn test_conversation_id_default_is_not_zeroes() { - let id = ConversationId::default(); - assert_ne!(id.uuid, Uuid::nil()); - } - #[test] fn conversation_id_serializes_as_plain_string() -> Result<()> { let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; @@ -883,4 +893,39 @@ mod tests { ); Ok(()) } + + #[test] + fn serialize_server_request() -> Result<()> { + let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let params = ExecCommandApprovalParams { + conversation_id, + call_id: "call-42".to_string(), + command: vec!["echo".to_string(), "hello".to_string()], + cwd: PathBuf::from("/tmp"), + reason: Some("because tests".to_string()), + }; + let request = ServerRequest::ExecCommandApproval { + request_id: RequestId::Integer(7), + params: params.clone(), + }; + + assert_eq!( + json!({ + "method": "execCommandApproval", + "id": 7, + "params": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "callId": "call-42", + "command": ["echo", "hello"], + "cwd": "/tmp", + "reason": "because tests", + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::ExecCommandApproval(params); + assert_eq!(payload.request_with_id(RequestId::Integer(7)), request); + Ok(()) + } } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 1cc25ef2d1..545ef4898c 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -22,10 +22,8 @@ codex-core = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-utils-json-to-toml = { workspace = true } -# We should only be using mcp-types for JSON-RPC types: it would be nice to -# split this out into a separate crate at some point. -mcp-types = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 4d11aad421..1e5eb82ef0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3,6 +3,52 @@ use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::fuzzy_file_search::run_fuzzy_file_search; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::AddConversationSubscriptionResponse; +use codex_app_server_protocol::ApplyPatchApprovalParams; +use codex_app_server_protocol::ApplyPatchApprovalResponse; +use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::ArchiveConversationResponse; +use codex_app_server_protocol::AuthStatusChangeNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::ExecCommandApprovalParams; +use codex_app_server_protocol::ExecCommandApprovalResponse; +use codex_app_server_protocol::ExecOneOffCommandParams; +use codex_app_server_protocol::ExecOneOffCommandResponse; +use codex_app_server_protocol::FuzzyFileSearchParams; +use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::GetUserAgentResponse; +use codex_app_server_protocol::GetUserSavedConfigResponse; +use codex_app_server_protocol::GitDiffToRemoteResponse; +use codex_app_server_protocol::InputItem as WireInputItem; +use codex_app_server_protocol::InterruptConversationParams; +use codex_app_server_protocol::InterruptConversationResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ListConversationsParams; +use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LoginApiKeyResponse; +use codex_app_server_protocol::LoginChatGptCompleteNotification; +use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::NewConversationParams; +use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::RemoveConversationListenerParams; +use codex_app_server_protocol::RemoveConversationSubscriptionResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result as JsonRpcResult; +use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::SendUserMessageParams; +use codex_app_server_protocol::SendUserMessageResponse; +use codex_app_server_protocol::SendUserTurnParams; +use codex_app_server_protocol::SendUserTurnResponse; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::SessionConfiguredNotification; +use codex_app_server_protocol::SetDefaultModelParams; +use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::UserInfoResponse; +use codex_app_server_protocol::UserSavedConfig; use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; @@ -36,58 +82,12 @@ use codex_core::protocol::ReviewDecision; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; -use codex_protocol::mcp_protocol::APPLY_PATCH_APPROVAL_METHOD; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::ApplyPatchApprovalParams; -use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse; -use codex_protocol::mcp_protocol::ArchiveConversationParams; -use codex_protocol::mcp_protocol::ArchiveConversationResponse; -use codex_protocol::mcp_protocol::AuthStatusChangeNotification; -use codex_protocol::mcp_protocol::ClientRequest; -use codex_protocol::mcp_protocol::ConversationId; -use codex_protocol::mcp_protocol::ConversationSummary; -use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; -use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse; -use codex_protocol::mcp_protocol::ExecCommandApprovalParams; -use codex_protocol::mcp_protocol::ExecCommandApprovalResponse; -use codex_protocol::mcp_protocol::ExecOneOffCommandParams; -use codex_protocol::mcp_protocol::FuzzyFileSearchParams; -use codex_protocol::mcp_protocol::FuzzyFileSearchResponse; -use codex_protocol::mcp_protocol::GetUserAgentResponse; -use codex_protocol::mcp_protocol::GetUserSavedConfigResponse; -use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; -use codex_protocol::mcp_protocol::InputItem as WireInputItem; -use codex_protocol::mcp_protocol::InterruptConversationParams; -use codex_protocol::mcp_protocol::InterruptConversationResponse; -use codex_protocol::mcp_protocol::ListConversationsParams; -use codex_protocol::mcp_protocol::ListConversationsResponse; -use codex_protocol::mcp_protocol::LoginApiKeyParams; -use codex_protocol::mcp_protocol::LoginApiKeyResponse; -use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; -use codex_protocol::mcp_protocol::LoginChatGptResponse; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use codex_protocol::mcp_protocol::RemoveConversationListenerParams; -use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::ResumeConversationParams; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserMessageResponse; -use codex_protocol::mcp_protocol::SendUserTurnParams; -use codex_protocol::mcp_protocol::SendUserTurnResponse; -use codex_protocol::mcp_protocol::ServerNotification; -use codex_protocol::mcp_protocol::SessionConfiguredNotification; -use codex_protocol::mcp_protocol::SetDefaultModelParams; -use codex_protocol::mcp_protocol::SetDefaultModelResponse; -use codex_protocol::mcp_protocol::UserInfoResponse; -use codex_protocol::mcp_protocol::UserSavedConfig; +use codex_protocol::ConversationId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InputMessageKind; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_utils_json_to_toml::json_to_toml; -use mcp_types::JSONRPCErrorError; -use mcp_types::RequestId; use std::collections::HashMap; use std::ffi::OsStr; use std::path::PathBuf; @@ -193,28 +193,43 @@ impl CodexMessageProcessor { ClientRequest::LoginApiKey { request_id, params } => { self.login_api_key(request_id, params).await; } - ClientRequest::LoginChatGpt { request_id } => { + ClientRequest::LoginChatGpt { + request_id, + params: _, + } => { self.login_chatgpt(request_id).await; } ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; } - ClientRequest::LogoutChatGpt { request_id } => { + ClientRequest::LogoutChatGpt { + request_id, + params: _, + } => { self.logout_chatgpt(request_id).await; } ClientRequest::GetAuthStatus { request_id, params } => { self.get_auth_status(request_id, params).await; } - ClientRequest::GetUserSavedConfig { request_id } => { + ClientRequest::GetUserSavedConfig { + request_id, + params: _, + } => { self.get_user_saved_config(request_id).await; } ClientRequest::SetDefaultModel { request_id, params } => { self.set_default_model(request_id, params).await; } - ClientRequest::GetUserAgent { request_id } => { + ClientRequest::GetUserAgent { + request_id, + params: _, + } => { self.get_user_agent(request_id).await; } - ClientRequest::UserInfo { request_id } => { + ClientRequest::UserInfo { + request_id, + params: _, + } => { self.get_user_info(request_id).await; } ClientRequest::FuzzyFileSearch { request_id, params } => { @@ -371,7 +386,7 @@ impl CodexMessageProcessor { self.outgoing .send_response( request_id, - codex_protocol::mcp_protocol::CancelLoginChatGptResponse {}, + codex_app_server_protocol::CancelLoginChatGptResponse {}, ) .await; } else { @@ -407,7 +422,7 @@ impl CodexMessageProcessor { self.outgoing .send_response( request_id, - codex_protocol::mcp_protocol::LogoutChatGptResponse {}, + codex_app_server_protocol::LogoutChatGptResponse {}, ) .await; @@ -425,7 +440,7 @@ impl CodexMessageProcessor { async fn get_auth_status( &self, request_id: RequestId, - params: codex_protocol::mcp_protocol::GetAuthStatusParams, + params: codex_app_server_protocol::GetAuthStatusParams, ) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); @@ -440,7 +455,7 @@ impl CodexMessageProcessor { let requires_openai_auth = self.config.model_provider.requires_openai_auth; let response = if !requires_openai_auth { - codex_protocol::mcp_protocol::GetAuthStatusResponse { + codex_app_server_protocol::GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(false), @@ -460,13 +475,13 @@ impl CodexMessageProcessor { (None, None) } }; - codex_protocol::mcp_protocol::GetAuthStatusResponse { + codex_app_server_protocol::GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, requires_openai_auth: Some(true), } } - None => codex_protocol::mcp_protocol::GetAuthStatusResponse { + None => codex_app_server_protocol::GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(true), @@ -617,7 +632,7 @@ impl CodexMessageProcessor { .await { Ok(output) => { - let response = ExecArbitraryCommandResponse { + let response = ExecOneOffCommandResponse { exit_code: output.exit_code, stdout: output.stdout.text, stderr: output.stderr.text, @@ -793,7 +808,7 @@ impl CodexMessageProcessor { }); // Reply with conversation id + model and initial messages (when present) - let response = codex_protocol::mcp_protocol::ResumeConversationResponse { + let response = codex_app_server_protocol::ResumeConversationResponse { conversation_id, model: session_configured.model.clone(), initial_messages, @@ -1253,9 +1268,8 @@ async fn apply_bespoke_event_handling( reason, grant_root, }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); let rx = outgoing - .send_request(APPLY_PATCH_APPROVAL_METHOD, Some(value)) + .send_request(ServerRequestPayload::ApplyPatchApproval(params)) .await; // TODO(mbolin): Enforce a timeout so this task does not live indefinitely? tokio::spawn(async move { @@ -1275,9 +1289,8 @@ async fn apply_bespoke_event_handling( cwd, reason, }; - let value = serde_json::to_value(¶ms).unwrap_or_default(); let rx = outgoing - .send_request(EXEC_COMMAND_APPROVAL_METHOD, Some(value)) + .send_request(ServerRequestPayload::ExecCommandApproval(params)) .await; // TODO(mbolin): Enforce a timeout so this task does not live indefinitely? @@ -1348,7 +1361,7 @@ fn derive_config_from_params( async fn on_patch_approval_response( event_id: String, - receiver: oneshot::Receiver, + receiver: oneshot::Receiver, codex: Arc, ) { let response = receiver.await; @@ -1390,7 +1403,7 @@ async fn on_patch_approval_response( async fn on_exec_approval_response( event_id: String, - receiver: oneshot::Receiver, + receiver: oneshot::Receiver, conversation: Arc, ) { let response = receiver.await; diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index eef760df26..146cc12a39 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; +use codex_app_server_protocol::FuzzyFileSearchResult; use codex_file_search as file_search; -use codex_protocol::mcp_protocol::FuzzyFileSearchResult; use tokio::task::JoinSet; use tracing::warn; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a0c40db64a..3e888c6960 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,7 +8,7 @@ use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use mcp_types::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCMessage; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; @@ -111,17 +111,17 @@ pub async fn run_main( let stdout_writer_handle = tokio::spawn(async move { let mut stdout = io::stdout(); while let Some(outgoing_message) = outgoing_rx.recv().await { - let msg: JSONRPCMessage = outgoing_message.into(); - match serde_json::to_string(&msg) { - Ok(json) => { + let Ok(value) = serde_json::to_value(outgoing_message) else { + error!("Failed to convert OutgoingMessage to JSON value"); + continue; + }; + match serde_json::to_string(&value) { + Ok(mut json) => { + json.push('\n'); if let Err(e) = stdout.write_all(json.as_bytes()).await { error!("Failed to write to stdout: {e}"); break; } - if let Err(e) = stdout.write_all(b"\n").await { - error!("Failed to write newline to stdout: {e}"); - break; - } } Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"), } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 24e0f2320a..1b9fcffb71 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -3,20 +3,20 @@ use std::path::PathBuf; use crate::codex_message_processor::CodexMessageProcessor; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; -use codex_protocol::mcp_protocol::ClientInfo; -use codex_protocol::mcp_protocol::ClientRequest; -use codex_protocol::mcp_protocol::InitializeResponse; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; -use mcp_types::JSONRPCError; -use mcp_types::JSONRPCErrorError; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCRequest; -use mcp_types::JSONRPCResponse; use std::sync::Arc; pub(crate) struct MessageProcessor { diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 25d5e7aa52..96a2c5a96c 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -2,16 +2,12 @@ use std::collections::HashMap; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; -use codex_protocol::mcp_protocol::ServerNotification; -use mcp_types::JSONRPC_VERSION; -use mcp_types::JSONRPCError; -use mcp_types::JSONRPCErrorError; -use mcp_types::JSONRPCMessage; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCRequest; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; -use mcp_types::Result; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::Result; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerRequestPayload; use serde::Serialize; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -38,8 +34,7 @@ impl OutgoingMessageSender { pub(crate) async fn send_request( &self, - method: &str, - params: Option, + request: ServerRequestPayload, ) -> oneshot::Receiver { let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed)); let outgoing_message_id = id.clone(); @@ -49,11 +44,8 @@ impl OutgoingMessageSender { request_id_to_callback.insert(id, tx_approve); } - let outgoing_message = OutgoingMessage::Request(OutgoingRequest { - id: outgoing_message_id, - method: method.to_string(), - params, - }); + let outgoing_message = + OutgoingMessage::Request(request.request_with_id(outgoing_message_id)); let _ = self.sender.send(outgoing_message); rx_approve } @@ -116,8 +108,10 @@ impl OutgoingMessageSender { } /// Outgoing message from the server to the client. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] pub(crate) enum OutgoingMessage { - Request(OutgoingRequest), + Request(ServerRequest), Notification(OutgoingNotification), /// AppServerNotification is specific to the case where this is run as an /// "app server" as opposed to an MCP server. @@ -126,64 +120,6 @@ pub(crate) enum OutgoingMessage { Error(OutgoingError), } -impl From for JSONRPCMessage { - fn from(val: OutgoingMessage) -> Self { - use OutgoingMessage::*; - match val { - Request(OutgoingRequest { id, method, params }) => { - JSONRPCMessage::Request(JSONRPCRequest { - jsonrpc: JSONRPC_VERSION.into(), - id, - method, - params, - }) - } - Notification(OutgoingNotification { method, params }) => { - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: JSONRPC_VERSION.into(), - method, - params, - }) - } - AppServerNotification(notification) => { - let method = notification.to_string(); - let params = match notification.to_params() { - Ok(params) => Some(params), - Err(err) => { - warn!("failed to serialize notification params: {err}"); - None - } - }; - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: JSONRPC_VERSION.into(), - method, - params, - }) - } - Response(OutgoingResponse { id, result }) => { - JSONRPCMessage::Response(JSONRPCResponse { - jsonrpc: JSONRPC_VERSION.into(), - id, - result, - }) - } - Error(OutgoingError { id, error }) => JSONRPCMessage::Error(JSONRPCError { - jsonrpc: JSONRPC_VERSION.into(), - id, - error, - }), - } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub(crate) struct OutgoingRequest { - pub id: RequestId, - pub method: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub params: Option, -} - #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingNotification { pub method: String, @@ -205,7 +141,7 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { - use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; + use codex_app_server_protocol::LoginChatGptCompleteNotification; use pretty_assertions::assert_eq; use serde_json::json; use uuid::Uuid; @@ -221,18 +157,17 @@ mod tests { error: None, }); - let jsonrpc_notification: JSONRPCMessage = - OutgoingMessage::AppServerNotification(notification).into(); + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); assert_eq!( - JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: "2.0".into(), - method: "loginChatGptComplete".into(), - params: Some(json!({ + json!({ + "method": "loginChatGptComplete", + "params": { "loginId": Uuid::nil(), "success": true, - })), + }, }), - jsonrpc_notification, + serde_json::to_value(jsonrpc_notification) + .expect("ensure the strum macros serialize the method field correctly"), "ensure the strum macros serialize the method field correctly" ); } diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 4cc711fb6b..306b1e187e 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -9,8 +9,7 @@ path = "lib.rs" [dependencies] anyhow = { workspace = true } assert_cmd = { workspace = true } -codex-protocol = { workspace = true } -mcp-types = { workspace = true } +codex-app-server-protocol = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index d088b184ea..2acb52de69 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -2,8 +2,8 @@ mod mcp_process; mod mock_model_server; mod responses; +use codex_app_server_protocol::JSONRPCResponse; pub use mcp_process::McpProcess; -use mcp_types::JSONRPCResponse; pub use mock_model_server::create_mock_chat_completions_server; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index cbf54cecf0..29768df81c 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -1,3 +1,4 @@ +use std::collections::VecDeque; use std::path::Path; use std::process::Stdio; use std::sync::atomic::AtomicI64; @@ -11,29 +12,30 @@ use tokio::process::ChildStdout; use anyhow::Context; use assert_cmd::prelude::*; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::ArchiveConversationParams; -use codex_protocol::mcp_protocol::CancelLoginChatGptParams; -use codex_protocol::mcp_protocol::ClientInfo; -use codex_protocol::mcp_protocol::ClientNotification; -use codex_protocol::mcp_protocol::GetAuthStatusParams; -use codex_protocol::mcp_protocol::InitializeParams; -use codex_protocol::mcp_protocol::InterruptConversationParams; -use codex_protocol::mcp_protocol::ListConversationsParams; -use codex_protocol::mcp_protocol::LoginApiKeyParams; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::RemoveConversationListenerParams; -use codex_protocol::mcp_protocol::ResumeConversationParams; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserTurnParams; -use codex_protocol::mcp_protocol::SetDefaultModelParams; +use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::CancelLoginChatGptParams; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InterruptConversationParams; +use codex_app_server_protocol::ListConversationsParams; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::NewConversationParams; +use codex_app_server_protocol::RemoveConversationListenerParams; +use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::SendUserMessageParams; +use codex_app_server_protocol::SendUserTurnParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SetDefaultModelParams; -use mcp_types::JSONRPC_VERSION; -use mcp_types::JSONRPCMessage; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCRequest; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; use std::process::Command as StdCommand; use tokio::process::Command; @@ -46,6 +48,7 @@ pub struct McpProcess { process: Child, stdin: ChildStdin, stdout: BufReader, + pending_user_messages: VecDeque, } impl McpProcess { @@ -116,6 +119,7 @@ impl McpProcess { process, stdin, stdout, + pending_user_messages: VecDeque::new(), }) } @@ -317,7 +321,6 @@ impl McpProcess { let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed); let message = JSONRPCMessage::Request(JSONRPCRequest { - jsonrpc: JSONRPC_VERSION.into(), id: RequestId::Integer(request_id), method: method.to_string(), params, @@ -331,12 +334,8 @@ impl McpProcess { id: RequestId, result: serde_json::Value, ) -> anyhow::Result<()> { - self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { - jsonrpc: JSONRPC_VERSION.into(), - id, - result, - })) - .await + self.send_jsonrpc_message(JSONRPCMessage::Response(JSONRPCResponse { id, result })) + .await } pub async fn send_notification( @@ -345,7 +344,6 @@ impl McpProcess { ) -> anyhow::Result<()> { let value = serde_json::to_value(notification)?; self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification { - jsonrpc: JSONRPC_VERSION.into(), method: value .get("method") .and_then(|m| m.as_str()) @@ -373,18 +371,21 @@ impl McpProcess { Ok(message) } - pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { + pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { eprintln!("in read_stream_until_request_message()"); loop { let message = self.read_jsonrpc_message().await?; match message { - JSONRPCMessage::Notification(_) => { - eprintln!("notification: {message:?}"); + JSONRPCMessage::Notification(notification) => { + eprintln!("notification: {notification:?}"); + self.enqueue_user_message(notification); } JSONRPCMessage::Request(jsonrpc_request) => { - return Ok(jsonrpc_request); + return jsonrpc_request.try_into().with_context( + || "failed to deserialize ServerRequest from JSONRPCRequest", + ); } JSONRPCMessage::Error(_) => { anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); @@ -405,8 +406,9 @@ impl McpProcess { loop { let message = self.read_jsonrpc_message().await?; match message { - JSONRPCMessage::Notification(_) => { - eprintln!("notification: {message:?}"); + JSONRPCMessage::Notification(notification) => { + eprintln!("notification: {notification:?}"); + self.enqueue_user_message(notification); } JSONRPCMessage::Request(_) => { anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); @@ -426,12 +428,13 @@ impl McpProcess { pub async fn read_stream_until_error_message( &mut self, request_id: RequestId, - ) -> anyhow::Result { + ) -> anyhow::Result { loop { let message = self.read_jsonrpc_message().await?; match message { - JSONRPCMessage::Notification(_) => { - eprintln!("notification: {message:?}"); + JSONRPCMessage::Notification(notification) => { + eprintln!("notification: {notification:?}"); + self.enqueue_user_message(notification); } JSONRPCMessage::Request(_) => { anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); @@ -454,6 +457,10 @@ impl McpProcess { ) -> anyhow::Result { eprintln!("in read_stream_until_notification_message({method})"); + if let Some(notification) = self.take_pending_notification_by_method(method) { + return Ok(notification); + } + loop { let message = self.read_jsonrpc_message().await?; match message { @@ -461,6 +468,7 @@ impl McpProcess { if notification.method == method { return Ok(notification); } + self.enqueue_user_message(notification); } JSONRPCMessage::Request(_) => { anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); @@ -474,4 +482,21 @@ impl McpProcess { } } } + + fn take_pending_notification_by_method(&mut self, method: &str) -> Option { + if let Some(pos) = self + .pending_user_messages + .iter() + .position(|notification| notification.method == method) + { + return self.pending_user_messages.remove(pos); + } + None + } + + fn enqueue_user_message(&mut self, notification: JSONRPCNotification) { + if notification.method == "codex/event/user_message" { + self.pending_user_messages.push_back(notification); + } + } } diff --git a/codex-rs/app-server/tests/suite/archive_conversation.rs b/codex-rs/app-server/tests/suite/archive_conversation.rs index 65a1589763..6dcfefdbb8 100644 --- a/codex-rs/app-server/tests/suite/archive_conversation.rs +++ b/codex-rs/app-server/tests/suite/archive_conversation.rs @@ -2,13 +2,13 @@ use std::path::Path; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::ArchiveConversationResponse; +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_core::ARCHIVED_SESSIONS_SUBDIR; -use codex_protocol::mcp_protocol::ArchiveConversationParams; -use codex_protocol::mcp_protocol::ArchiveConversationResponse; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index b19203d880..f45a27fda0 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -2,13 +2,13 @@ use std::path::Path; use app_test_support::McpProcess; use app_test_support::to_response; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::GetAuthStatusParams; -use codex_protocol::mcp_protocol::GetAuthStatusResponse; -use codex_protocol::mcp_protocol::LoginApiKeyParams; -use codex_protocol::mcp_protocol::LoginApiKeyResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LoginApiKeyResponse; +use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 7527411223..4dff2a1575 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -5,25 +5,31 @@ use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; use app_test_support::create_shell_sse_response; use app_test_support::to_response; +use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::AddConversationSubscriptionResponse; +use codex_app_server_protocol::ExecCommandApprovalParams; +use codex_app_server_protocol::InputItem; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::NewConversationParams; +use codex_app_server_protocol::NewConversationResponse; +use codex_app_server_protocol::RemoveConversationListenerParams; +use codex_app_server_protocol::RemoveConversationSubscriptionResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SendUserMessageParams; +use codex_app_server_protocol::SendUserMessageResponse; +use codex_app_server_protocol::SendUserTurnParams; +use codex_app_server_protocol::SendUserTurnResponse; +use codex_app_server_protocol::ServerRequest; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use codex_protocol::mcp_protocol::RemoveConversationListenerParams; -use codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserMessageResponse; -use codex_protocol::mcp_protocol::SendUserTurnParams; -use codex_protocol::mcp_protocol::SendUserTurnResponse; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::InputMessageKind; use pretty_assertions::assert_eq; use std::env; use tempfile::TempDir; @@ -115,7 +121,7 @@ async fn test_codex_jsonrpc_conversation_flow() { let send_user_id = mcp .send_send_user_message_request(SendUserMessageParams { conversation_id, - items: vec![codex_protocol::mcp_protocol::InputItem::Text { + items: vec![codex_app_server_protocol::InputItem::Text { text: "text".to_string(), }], }) @@ -265,7 +271,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { let send_user_id = mcp .send_send_user_message_request(SendUserMessageParams { conversation_id, - items: vec![codex_protocol::mcp_protocol::InputItem::Text { + items: vec![codex_app_server_protocol::InputItem::Text { text: "run python".to_string(), }], }) @@ -290,11 +296,28 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { .await .expect("waiting for exec approval request timeout") .expect("exec approval request"); - assert_eq!(request.method, EXEC_COMMAND_APPROVAL_METHOD); + let ServerRequest::ExecCommandApproval { request_id, params } = request else { + panic!("expected ExecCommandApproval request, got: {request:?}"); + }; + + assert_eq!( + ExecCommandApprovalParams { + conversation_id, + call_id: "call1".to_string(), + command: vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + cwd: working_directory.clone(), + reason: None, + }, + params + ); // Approve so the first turn can complete mcp.send_response( - request.id, + request_id, serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), ) .await @@ -313,7 +336,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { let send_turn_id = mcp .send_send_user_turn_request(SendUserTurnParams { conversation_id, - items: vec![codex_protocol::mcp_protocol::InputItem::Text { + items: vec![codex_app_server_protocol::InputItem::Text { text: "run python again".to_string(), }], cwd: working_directory.clone(), @@ -349,6 +372,234 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { } // Helper: minimal config.toml pointing at mock provider. + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { + if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + let tmp = TempDir::new().expect("tmp dir"); + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home).expect("create codex home dir"); + let workspace_root = tmp.path().join("workspace"); + std::fs::create_dir(&workspace_root).expect("create workspace root"); + let first_cwd = workspace_root.join("turn1"); + let second_cwd = workspace_root.join("turn2"); + std::fs::create_dir(&first_cwd).expect("create first cwd"); + std::fs::create_dir(&second_cwd).expect("create second cwd"); + + let responses = vec![ + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo first turn".to_string(), + ], + None, + Some(5000), + "call-first", + ) + .expect("create first shell response"), + create_final_assistant_message_sse_response("done first") + .expect("create first final assistant message"), + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string(), + ], + None, + Some(5000), + "call-second", + ) + .expect("create second shell response"), + create_final_assistant_message_sse_response("done second") + .expect("create second final assistant message"), + ]; + let server = create_mock_chat_completions_server(responses).await; + create_config_toml(&codex_home, &server.uri()).expect("write config"); + + let mut mcp = McpProcess::new(&codex_home) + .await + .expect("spawn mcp process"); + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) + .await + .expect("init timeout") + .expect("init failed"); + + let new_conv_id = mcp + .send_new_conversation_request(NewConversationParams { + cwd: Some(first_cwd.to_string_lossy().into_owned()), + approval_policy: Some(AskForApproval::Never), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await + .expect("send newConversation"); + let new_conv_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), + ) + .await + .expect("newConversation timeout") + .expect("newConversation resp"); + let NewConversationResponse { + conversation_id, + model, + .. + } = to_response::(new_conv_resp) + .expect("deserialize newConversation response"); + + let add_listener_id = mcp + .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .await + .expect("send addConversationListener"); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), + ) + .await + .expect("addConversationListener timeout") + .expect("addConversationListener resp"); + + let first_turn_id = mcp + .send_send_user_turn_request(SendUserTurnParams { + conversation_id, + items: vec![InputItem::Text { + text: "first turn".to_string(), + }], + cwd: first_cwd.clone(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::WorkspaceWrite { + writable_roots: vec![first_cwd.clone()], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, + model: model.clone(), + effort: Some(ReasoningEffort::Medium), + summary: ReasoningSummary::Auto, + }) + .await + .expect("send first sendUserTurn"); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await + .expect("sendUserTurn 1 timeout") + .expect("sendUserTurn 1 resp"); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await + .expect("task_complete 1 timeout") + .expect("task_complete 1 notification"); + + let second_turn_id = mcp + .send_send_user_turn_request(SendUserTurnParams { + conversation_id, + items: vec![InputItem::Text { + text: "second turn".to_string(), + }], + cwd: second_cwd.clone(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: model.clone(), + effort: Some(ReasoningEffort::Medium), + summary: ReasoningSummary::Auto, + }) + .await + .expect("send second sendUserTurn"); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await + .expect("sendUserTurn 2 timeout") + .expect("sendUserTurn 2 resp"); + + let mut env_message: Option = None; + let second_cwd_str = second_cwd.to_string_lossy().into_owned(); + for _ in 0..10 { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/user_message"), + ) + .await + .expect("user_message timeout") + .expect("user_message notification"); + let params = notification + .params + .clone() + .expect("user_message should include params"); + let event: Event = serde_json::from_value(params).expect("deserialize user_message event"); + if let EventMsg::UserMessage(user) = event.msg + && matches!(user.kind, Some(InputMessageKind::EnvironmentContext)) + && user.message.contains(&second_cwd_str) + { + env_message = Some(user.message); + break; + } + } + let env_message = env_message.expect("expected environment context update"); + assert!( + env_message.contains("danger-full-access"), + "env context should reflect new sandbox mode: {env_message}" + ); + assert!( + env_message.contains("enabled"), + "env context should enable network access for danger-full-access policy: {env_message}" + ); + assert!( + env_message.contains(&second_cwd_str), + "env context should include updated cwd: {env_message}" + ); + + let exec_begin_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/exec_command_begin"), + ) + .await + .expect("exec_command_begin timeout") + .expect("exec_command_begin notification"); + let params = exec_begin_notification + .params + .clone() + .expect("exec_command_begin params"); + let event: Event = serde_json::from_value(params).expect("deserialize exec begin event"); + let exec_begin = match event.msg { + EventMsg::ExecCommandBegin(exec_begin) => exec_begin, + other => panic!("expected ExecCommandBegin event, got {other:?}"), + }; + assert_eq!( + exec_begin.cwd, second_cwd, + "exec turn should run from updated cwd" + ); + assert_eq!( + exec_begin.command, + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string() + ], + "exec turn should run expected command" + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await + .expect("task_complete 2 timeout") + .expect("task_complete 2 notification"); +} + fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 13cb6c4a2f..577eeb388d 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -3,18 +3,18 @@ use std::path::Path; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::GetUserSavedConfigResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::Profile; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxSettings; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; -use codex_protocol::mcp_protocol::GetUserSavedConfigResponse; -use codex_protocol::mcp_protocol::Profile; -use codex_protocol::mcp_protocol::SandboxSettings; -use codex_protocol::mcp_protocol::Tools; -use codex_protocol::mcp_protocol::UserSavedConfig; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/create_conversation.rs b/codex-rs/app-server/tests/suite/create_conversation.rs index d5896fffcb..37a0db84fa 100644 --- a/codex-rs/app-server/tests/suite/create_conversation.rs +++ b/codex-rs/app-server/tests/suite/create_conversation.rs @@ -4,15 +4,15 @@ use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; use app_test_support::to_response; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::InputItem; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserMessageResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::AddConversationSubscriptionResponse; +use codex_app_server_protocol::InputItem; +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 pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 12cbb7d574..8e33b130e1 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,6 +1,6 @@ use app_test_support::McpProcess; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 087f1b6078..2500d20ff4 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -3,17 +3,17 @@ 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 codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::InterruptConversationParams; -use codex_protocol::mcp_protocol::InterruptConversationResponse; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserMessageResponse; use core_test_support::skip_if_no_network; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use tempfile::TempDir; use tokio::time::timeout; @@ -100,7 +100,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> { let send_user_id = mcp .send_send_user_message_request(SendUserMessageParams { conversation_id, - items: vec![codex_protocol::mcp_protocol::InputItem::Text { + items: vec![codex_app_server_protocol::InputItem::Text { text: "run first sleep command".to_string(), }], }) diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 9e91fdd9cd..a4178b0873 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -3,16 +3,16 @@ use std::path::Path; use app_test_support::McpProcess; use app_test_support::to_response; -use codex_protocol::mcp_protocol::ListConversationsParams; -use codex_protocol::mcp_protocol::ListConversationsResponse; -use codex_protocol::mcp_protocol::NewConversationParams; // reused for overrides shape -use codex_protocol::mcp_protocol::ResumeConversationParams; -use codex_protocol::mcp_protocol::ResumeConversationResponse; -use codex_protocol::mcp_protocol::ServerNotification; -use codex_protocol::mcp_protocol::SessionConfiguredNotification; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ListConversationsParams; +use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::NewConversationParams; // reused for overrides shape +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::ResumeConversationResponse; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SessionConfiguredNotification; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index 475af9ab57..6dcbde1125 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -3,15 +3,15 @@ use std::time::Duration; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::CancelLoginChatGptParams; +use codex_app_server_protocol::CancelLoginChatGptResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::LogoutChatGptResponse; +use codex_app_server_protocol::RequestId; use codex_login::login_with_api_key; -use codex_protocol::mcp_protocol::CancelLoginChatGptParams; -use codex_protocol::mcp_protocol::CancelLoginChatGptResponse; -use codex_protocol::mcp_protocol::GetAuthStatusParams; -use codex_protocol::mcp_protocol::GetAuthStatusResponse; -use codex_protocol::mcp_protocol::LoginChatGptResponse; -use codex_protocol::mcp_protocol::LogoutChatGptResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 81da0096f4..22fb02dca9 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -4,17 +4,17 @@ use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; use app_test_support::to_response; -use codex_protocol::mcp_protocol::AddConversationListenerParams; -use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; -use codex_protocol::mcp_protocol::ConversationId; -use codex_protocol::mcp_protocol::InputItem; -use codex_protocol::mcp_protocol::NewConversationParams; -use codex_protocol::mcp_protocol::NewConversationResponse; -use codex_protocol::mcp_protocol::SendUserMessageParams; -use codex_protocol::mcp_protocol::SendUserMessageResponse; -use mcp_types::JSONRPCNotification; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::AddConversationListenerParams; +use codex_app_server_protocol::AddConversationSubscriptionResponse; +use codex_app_server_protocol::InputItem; +use codex_app_server_protocol::JSONRPCNotification; +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_protocol::ConversationId; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs index ce0d79d497..6769faa9dc 100644 --- a/codex-rs/app-server/tests/suite/set_default_model.rs +++ b/codex-rs/app-server/tests/suite/set_default_model.rs @@ -2,11 +2,11 @@ use std::path::Path; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SetDefaultModelParams; +use codex_app_server_protocol::SetDefaultModelResponse; use codex_core::config::ConfigToml; -use codex_protocol::mcp_protocol::SetDefaultModelParams; -use codex_protocol::mcp_protocol::SetDefaultModelResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/user_agent.rs b/codex-rs/app-server/tests/suite/user_agent.rs index a7f75eab37..95a0b1a3e0 100644 --- a/codex-rs/app-server/tests/suite/user_agent.rs +++ b/codex-rs/app-server/tests/suite/user_agent.rs @@ -1,8 +1,8 @@ use app_test_support::McpProcess; use app_test_support::to_response; -use codex_protocol::mcp_protocol::GetUserAgentResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; +use codex_app_server_protocol::GetUserAgentResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs index 10ca7d330a..edd041e553 100644 --- a/codex-rs/app-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -5,14 +5,14 @@ use app_test_support::McpProcess; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::UserInfoResponse; use codex_core::auth::AuthDotJson; use codex_core::auth::get_auth_file; use codex_core::auth::write_auth_json; use codex_core::token_data::IdTokenInfo; use codex_core::token_data::TokenData; -use codex_protocol::mcp_protocol::UserInfoResponse; -use mcp_types::JSONRPCResponse; -use mcp_types::RequestId; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c62a267c73..c4dac80ec3 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -28,6 +28,7 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } @@ -43,8 +44,6 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } [dev-dependencies] assert_cmd = { workspace = true } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c6d80c0adf..c05d570e64 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -1,7 +1,6 @@ pub mod debug_sandbox; mod exit_status; pub mod login; -pub mod proto; use clap::Parser; use codex_common::CliConfigOverrides; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 8dd4fb8333..2b497c0642 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,3 +1,4 @@ +use codex_app_server_protocol::AuthMode; use codex_common::CliConfigOverrides; use codex_core::CodexAuth; use codex_core::auth::CLIENT_ID; @@ -8,7 +9,6 @@ use codex_core::config::ConfigOverrides; use codex_login::ServerOptions; use codex_login::run_device_code_login; use codex_login::run_login_server; -use codex_protocol::mcp_protocol::AuthMode; use std::path::PathBuf; pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b99577d092..10e2621533 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -12,7 +12,6 @@ use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_device_code; use codex_cli::login::run_logout; -use codex_cli::proto; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; use codex_exec::Cli as ExecCli; @@ -26,7 +25,6 @@ use supports_color::Stream; mod mcp_cmd; use crate::mcp_cmd::McpCli; -use crate::proto::ProtoCli; /// Codex CLI /// @@ -74,10 +72,6 @@ enum Subcommand { /// [experimental] Run the app server. AppServer, - /// Run the Protocol stream via stdin/stdout - #[clap(visible_alias = "p")] - Proto(ProtoCli), - /// Generate shell completion scripts. Completion(CompletionCommand), @@ -224,25 +218,12 @@ fn print_exit_messages(exit_info: AppExitInfo) { } } -pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE"; - -/// As early as possible in the process lifecycle, apply hardening measures -/// if the CODEX_SECURE_MODE environment variable is set to "1". +/// As early as possible in the process lifecycle, apply hardening measures. We +/// skip this in debug builds to avoid interfering with debugging. #[ctor::ctor] +#[cfg(not(debug_assertions))] fn pre_main_hardening() { - let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) { - Ok(value) => value, - Err(_) => return, - }; - - if secure_mode == "1" { - codex_process_hardening::pre_main_hardening(); - } - - // Always clear this env var so child processes don't inherit it. - unsafe { - std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR); - } + codex_process_hardening::pre_main_hardening(); } fn main() -> anyhow::Result<()> { @@ -332,13 +313,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ); run_logout(logout_cli.config_overrides).await; } - Some(Subcommand::Proto(mut proto_cli)) => { - prepend_config_flags( - &mut proto_cli.config_overrides, - root_config_overrides.clone(), - ); - proto::run_main(proto_cli).await?; - } Some(Subcommand::Completion(completion_cli)) => { print_completion(completion_cli); } @@ -481,7 +455,7 @@ fn print_completion(cmd: CompletionCommand) { mod tests { use super::*; use codex_core::protocol::TokenUsage; - use codex_protocol::mcp_protocol::ConversationId; + use codex_protocol::ConversationId; fn finalize_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs deleted file mode 100644 index 623edca5a8..0000000000 --- a/codex-rs/cli/src/proto.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::io::IsTerminal; - -use clap::Parser; -use codex_common::CliConfigOverrides; -use codex_core::AuthManager; -use codex_core::ConversationManager; -use codex_core::NewConversation; -use codex_core::config::Config; -use codex_core::config::ConfigOverrides; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Submission; -use tokio::io::AsyncBufReadExt; -use tokio::io::BufReader; -use tracing::error; -use tracing::info; - -#[derive(Debug, Parser)] -pub struct ProtoCli { - #[clap(skip)] - pub config_overrides: CliConfigOverrides, -} - -pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { - if std::io::stdin().is_terminal() { - anyhow::bail!("Protocol mode expects stdin to be a pipe, not a terminal"); - } - - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .init(); - - let ProtoCli { config_overrides } = opts; - let overrides_vec = config_overrides - .parse_overrides() - .map_err(anyhow::Error::msg)?; - - let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; - // Use conversation_manager API to start a conversation - let conversation_manager = - ConversationManager::new(AuthManager::shared(config.codex_home.clone())); - let NewConversation { - conversation_id: _, - conversation, - session_configured, - } = conversation_manager.new_conversation(config).await?; - - // Simulate streaming the session_configured event. - let synthetic_event = Event { - // Fake id value. - id: "".to_string(), - msg: EventMsg::SessionConfigured(session_configured), - }; - let session_configured_event = match serde_json::to_string(&synthetic_event) { - Ok(s) => s, - Err(e) => { - error!("Failed to serialize session_configured: {e}"); - return Err(anyhow::Error::from(e)); - } - }; - println!("{session_configured_event}"); - - // Task that reads JSON lines from stdin and forwards to Submission Queue - let sq_fut = { - let conversation = conversation.clone(); - async move { - let stdin = BufReader::new(tokio::io::stdin()); - let mut lines = stdin.lines(); - loop { - let result = tokio::select! { - _ = tokio::signal::ctrl_c() => { - break - }, - res = lines.next_line() => res, - }; - - match result { - Ok(Some(line)) => { - let line = line.trim(); - if line.is_empty() { - continue; - } - match serde_json::from_str::(line) { - Ok(sub) => { - if let Err(e) = conversation.submit_with_id(sub).await { - error!("{e:#}"); - break; - } - } - Err(e) => { - error!("invalid submission: {e}"); - } - } - } - _ => { - info!("Submission queue closed"); - break; - } - } - } - } - }; - - // Task that reads events from the agent and prints them as JSON lines to stdout - let eq_fut = async move { - loop { - let event = tokio::select! { - _ = tokio::signal::ctrl_c() => break, - event = conversation.next_event() => event, - }; - match event { - Ok(event) => { - let event_str = match serde_json::to_string(&event) { - Ok(s) => s, - Err(e) => { - error!("Failed to serialize event: {e}"); - continue; - } - }; - println!("{event_str}"); - } - Err(e) => { - error!("{e:#}"); - break; - } - } - } - info!("Event queue closed"); - }; - - tokio::join!(sq_fut, eq_fut); - Ok(()) -} diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 3ce84a6f50..d8f30cc09d 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -10,6 +10,7 @@ workspace = true clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } codex-core = { workspace = true } codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } serde = { workspace = true, optional = true } toml = { workspace = true, optional = true } diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 1acfee88f6..68acb5ce90 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -1,5 +1,5 @@ +use codex_app_server_protocol::AuthMode; use codex_core::protocol_config_types::ReasoningEffort; -use codex_protocol::mcp_protocol::AuthMode; /// A simple preset pairing a model slug with a reasoning effort. #[derive(Debug, Clone, Copy)] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 253fd57aed..442d6408e2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -24,6 +24,7 @@ codex-file-search = { workspace = true } codex-mcp-client = { workspace = true } codex-rmcp-client = { workspace = true } codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-utils-string = { workspace = true } dirs = { workspace = true } diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 9a298f460f..acff4b2f9e 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -5,6 +5,7 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL - The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. - Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary. - When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) +- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}). ## Editing constraints diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 5ba6fdf7b2..b68540615a 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; -use codex_protocol::mcp_protocol::AuthMode; +use codex_app_server_protocol::AuthMode; use crate::token_data::PlanType; use crate::token_data::TokenData; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 685fbf45f2..9e0c1c0430 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -6,8 +6,8 @@ use std::time::Duration; use crate::AuthManager; use crate::auth::CodexAuth; use bytes::Bytes; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::ConversationId; +use codex_app_server_protocol::AuthMode; +use codex_protocol::ConversationId; use eventsource_stream::Eventsource; use futures::prelude::*; use regex_lite::Regex; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7cf994baac..9397425897 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -15,6 +15,9 @@ use async_channel::Receiver; use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_protocol::mcp_protocol::ConversationId; +use codex_apply_patch::MaybeApplyPatchVerified; +use codex_apply_patch::maybe_parse_apply_patch_verified; +use codex_protocol::ConversationId; use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ReviewRequest; @@ -35,6 +38,9 @@ use tracing::trace; use tracing::warn; use crate::ModelProviderInfo; +use crate::apply_patch; +use crate::apply_patch::ApplyPatchExec; +use crate::apply_patch::InternalApplyPatchInvocation; use crate::apply_patch::convert_apply_patch_to_protocol; use crate::client::ModelClient; use crate::client_common::Prompt; @@ -45,15 +51,25 @@ use crate::conversation_history::ConversationHistory; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::error::SandboxErr; +use crate::exec::ExecParams; use crate::exec::ExecToolCallOutput; #[cfg(test)] +use crate::exec::StdoutStream; +#[cfg(test)] use crate::exec::StreamOutput; +use crate::exec_command::EXEC_COMMAND_TOOL_NAME; use crate::exec_command::ExecCommandParams; use crate::exec_command::ExecSessionManager; use crate::exec_command::WriteStdinParams; use crate::executor::Executor; use crate::executor::ExecutorConfig; use crate::executor::normalize_exec_result; +use crate::exec_env::create_env; +use crate::executor::ExecutionMode; +use crate::executor::Executor; +use crate::executor::ExecutorConfig; +use crate::executor::normalize_exec_result; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; @@ -1285,8 +1301,19 @@ async fn submission_loop( let previous_env_context = EnvironmentContext::from(turn_context.as_ref()); let new_env_context = EnvironmentContext::from(&fresh_turn_context); if !new_env_context.equals_except_shell(&previous_env_context) { - sess.record_conversation_items(&[ResponseItem::from(new_env_context)]) + let env_response_item = ResponseItem::from(new_env_context); + sess.record_conversation_items(std::slice::from_ref(&env_response_item)) .await; + for msg in map_response_item_to_event_messages( + &env_response_item, + sess.show_raw_agent_reasoning(), + ) { + let event = Event { + id: sub.id.clone(), + msg, + }; + sess.send_event(event).await; + } } // Install the new persistent context for subsequent tasks/turns. @@ -2348,6 +2375,8 @@ use crate::executor::errors::ExecError; use crate::executor::linkers::PreparedExec; use crate::tools::context::ApplyPatchCommandContext; use crate::tools::context::ExecCommandContext; +use crate::executor::errors::ExecError; +use crate::executor::linkers::PreparedExec; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2363,6 +2392,7 @@ mod tests { use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; + use codex_app_server_protocol::AuthMode; use crate::tools::MODEL_FORMAT_HEAD_LINES; use crate::tools::MODEL_FORMAT_MAX_BYTES; use crate::tools::MODEL_FORMAT_MAX_LINES; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 865bc9251f..af595b495d 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -23,12 +23,12 @@ use crate::openai_model_info::get_model_info; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; use anyhow::Context; +use codex_app_server_protocol::Tools; +use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; -use codex_protocol::mcp_protocol::Tools; -use codex_protocol::mcp_protocol::UserSavedConfig; use dirs::home_dir; use serde::Deserialize; use std::collections::BTreeMap; diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index b98f78d311..da52176068 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -22,7 +22,7 @@ pub struct ConfigProfile { pub experimental_instructions_file: Option, } -impl From for codex_protocol::mcp_protocol::Profile { +impl From for codex_app_server_protocol::Profile { fn from(config_profile: ConfigProfile) -> Self { Self { model: config_profile.model, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 4728e76ef4..1b8f3ac067 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -313,7 +313,7 @@ pub struct SandboxWorkspaceWrite { pub exclude_slash_tmp: bool, } -impl From for codex_protocol::mcp_protocol::SandboxSettings { +impl From for codex_app_server_protocol::SandboxSettings { fn from(sandbox_workspace_write: SandboxWorkspaceWrite) -> Self { Self { writable_roots: sandbox_workspace_write.writable_roots, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 51854248a4..73d9b658ce 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -13,7 +13,7 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; use crate::rollout::RolloutRecorder; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index c5965dae8d..c6cc8940fa 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -1,7 +1,7 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; use serde_json; diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs index 97d7b29294..653a5d9097 100644 --- a/codex-rs/core/src/executor/mod.rs +++ b/codex-rs/core/src/executor/mod.rs @@ -10,6 +10,7 @@ pub(crate) use runner::ExecutorConfig; pub(crate) use runner::normalize_exec_result; pub(crate) mod linkers { + use crate::codex::ExecCommandContext; use crate::exec::ExecParams; use crate::exec::StdoutStream; use crate::executor::backends::ExecutionMode; diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs index db67276177..31bc112202 100644 --- a/codex-rs/core/src/executor/runner.rs +++ b/codex-rs/core/src/executor/runner.rs @@ -6,6 +6,7 @@ use std::time::Duration; use super::backends::ExecutionMode; use super::backends::backend_for_mode; use super::cache::ApprovalCache; +use crate::codex::ExecCommandContext; use crate::codex::Session; use crate::error::CodexErr; use crate::error::SandboxErr; @@ -131,19 +132,19 @@ impl Executor { .await; // Step 5: Handle sandbox outcomes, optionally escalating to an unsandboxed retry. - let raw_output = match first_attempt { - Ok(output) => output, + match first_attempt { + Ok(output) => Ok(output), Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => { - return Err(CodexErr::Sandbox(SandboxErr::Timeout { output }).into()); + Err(CodexErr::Sandbox(SandboxErr::Timeout { output }).into()) } - Err(CodexErr::Sandbox(error @ SandboxErr::Denied { .. })) => { - return if sandbox_decision.escalate_on_failure { + Err(CodexErr::Sandbox(error)) => { + if sandbox_decision.escalate_on_failure { self.retry_without_sandbox( &request, &config, session, context, - stdout_stream.clone(), + stdout_stream, error, ) .await @@ -152,12 +153,10 @@ impl Executor { "failed in sandbox {:?} with execution error: {error:?}", sandbox_decision.initial_sandbox ))) - }; + } } - Err(err) => return Err(err.into()), - }; - - Ok(raw_output) + Err(err) => Err(err.into()), + } } /// Fallback path invoked when a sandboxed run is denied so the user can diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 63ef82d77d..640ae4ab26 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; -use codex_protocol::mcp_protocol::GitSha; +use codex_app_server_protocol::GitSha; use codex_protocol::protocol::GitInfo; use futures::future::join_all; use serde::Deserialize; diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index bfd8bf0a9e..d102b2ef7c 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -30,7 +30,7 @@ use tokio::io::AsyncReadExt; use crate::config::Config; use crate::config_types::HistoryPersistence; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 2850996dcf..a5707b34d1 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -6,7 +6,7 @@ //! key. These override or extend the defaults at runtime. use crate::CodexAuth; -use codex_protocol::mcp_protocol::AuthMode; +use codex_app_server_protocol::AuthMode; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 6d99dd1357..f67c076c04 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -6,7 +6,7 @@ use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index af5234d31b..de9c9cef58 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -18,7 +18,7 @@ use crate::rollout::list::Cursor; use crate::rollout::list::get_conversation; use crate::rollout::list::get_conversations; use anyhow::Result; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 4551c5ca59..9e10b37822 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use codex_app_server_protocol::AuthMode; use codex_core::ContentItem; use codex_core::LocalShellAction; use codex_core::LocalShellExecAction; @@ -12,8 +13,7 @@ use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use core_test_support::load_default_config_for_test; use futures::StreamExt; use serde_json::Value; diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 9338a1af15..d8a8212932 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use tracing_test::traced_test; +use codex_app_server_protocol::AuthMode; use codex_core::ContentItem; use codex_core::ModelClient; use codex_core::ModelProviderInfo; @@ -10,8 +11,7 @@ use codex_core::ResponseItem; use codex_core::WireApi; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use core_test_support::load_default_config_for_test; use futures::StreamExt; use tempfile::TempDir; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 7b8eb42916..d4ff9da2d6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_app_server_protocol::AuthMode; use codex_core::CodexAuth; use codex_core::ContentItem; use codex_core::ConversationManager; @@ -17,8 +18,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use core_test_support::load_default_config_for_test; diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0df114cb00..57217b1f84 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -64,20 +64,9 @@ pub struct Cli { pub color: Color, /// Print events to stdout as JSONL. - #[arg( - long = "json", - default_value_t = false, - conflicts_with = "experimental_json" - )] + #[arg(long = "json", alias = "experimental-json", default_value_t = false)] pub json: bool, - #[arg( - long = "experimental-json", - default_value_t = false, - conflicts_with = "json" - )] - pub experimental_json: bool, - /// Whether to include the plan tool in the conversation. #[arg(long = "include-plan-tool", default_value_t = false)] pub include_plan_tool: bool, diff --git a/codex-rs/exec/src/event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_json_output.rs deleted file mode 100644 index c9d1127f79..0000000000 --- a/codex-rs/exec/src/event_processor_with_json_output.rs +++ /dev/null @@ -1,64 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::TaskCompleteEvent; -use serde_json::json; - -use crate::event_processor::CodexStatus; -use crate::event_processor::EventProcessor; -use crate::event_processor::handle_last_message; -use codex_common::create_config_summary_entries; - -pub(crate) struct EventProcessorWithJsonOutput { - last_message_path: Option, -} - -impl EventProcessorWithJsonOutput { - pub fn new(last_message_path: Option) -> Self { - Self { last_message_path } - } -} - -impl EventProcessor for EventProcessorWithJsonOutput { - fn print_config_summary(&mut self, config: &Config, prompt: &str, _: &SessionConfiguredEvent) { - let entries = create_config_summary_entries(config) - .into_iter() - .map(|(key, value)| (key.to_string(), value)) - .collect::>(); - #[expect(clippy::expect_used)] - let config_json = - serde_json::to_string(&entries).expect("Failed to serialize config summary to JSON"); - println!("{config_json}"); - - let prompt_json = json!({ - "prompt": prompt, - }); - println!("{prompt_json}"); - } - - fn process_event(&mut self, event: Event) -> CodexStatus { - match event.msg { - EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { - // Suppress streaming events in JSON mode. - CodexStatus::Running - } - EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { - if let Some(output_file) = self.last_message_path.as_deref() { - handle_last_message(last_agent_message.as_deref(), output_file); - } - CodexStatus::InitiateShutdown - } - EventMsg::ShutdownComplete => CodexStatus::Shutdown, - _ => { - if let Ok(line) = serde_json::to_string(&event) { - println!("{line}"); - } - CodexStatus::Running - } - } - } -} diff --git a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs similarity index 95% rename from codex-rs/exec/src/experimental_event_processor_with_json_output.rs rename to codex-rs/exec/src/event_processor_with_jsonl_output.rs index 164ebecefa..b3c82fc943 100644 --- a/codex-rs/exec/src/experimental_event_processor_with_json_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -29,6 +29,7 @@ use crate::exec_events::TurnCompletedEvent; use crate::exec_events::TurnFailedEvent; use crate::exec_events::TurnStartedEvent; use crate::exec_events::Usage; +use crate::exec_events::WebSearchItem; use codex_core::config::Config; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; @@ -46,10 +47,11 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::WebSearchEndEvent; use tracing::error; use tracing::warn; -pub struct ExperimentalEventProcessorWithJsonOutput { +pub struct EventProcessorWithJsonOutput { last_message_path: Option, next_event_id: AtomicU64, // Tracks running commands by call_id, including the associated item id. @@ -81,7 +83,7 @@ struct RunningMcpToolCall { item_id: String, } -impl ExperimentalEventProcessorWithJsonOutput { +impl EventProcessorWithJsonOutput { pub fn new(last_message_path: Option) -> Self { Self { last_message_path, @@ -106,6 +108,8 @@ impl ExperimentalEventProcessorWithJsonOutput { EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.handle_patch_apply_end(ev), + EventMsg::WebSearchBegin(_) => Vec::new(), + EventMsg::WebSearchEnd(ev) => self.handle_web_search_end(ev), EventMsg::TokenCount(ev) => { if let Some(info) = &ev.info { self.last_total_token_usage = Some(info.total_token_usage.clone()); @@ -143,6 +147,17 @@ impl ExperimentalEventProcessorWithJsonOutput { })] } + fn handle_web_search_end(&self, ev: &WebSearchEndEvent) -> Vec { + let item = ThreadItem { + id: self.get_next_item_id(), + details: ThreadItemDetails::WebSearch(WebSearchItem { + query: ev.query.clone(), + }), + }; + + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -405,7 +420,7 @@ impl ExperimentalEventProcessorWithJsonOutput { } } -impl EventProcessor for ExperimentalEventProcessorWithJsonOutput { +impl EventProcessor for EventProcessorWithJsonOutput { fn print_config_summary(&mut self, _: &Config, _: &str, ev: &SessionConfiguredEvent) { self.process_event(Event { id: "".to_string(), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6aaba3734f..4c471f340f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1,9 +1,8 @@ mod cli; mod event_processor; mod event_processor_with_human_output; -pub mod event_processor_with_json_output; +pub mod event_processor_with_jsonl_output; pub mod exec_events; -pub mod experimental_event_processor_with_json_output; pub use cli::Cli; use codex_core::AuthManager; @@ -22,8 +21,7 @@ use codex_core::protocol::TaskCompleteEvent; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use event_processor_with_human_output::EventProcessorWithHumanOutput; -use event_processor_with_json_output::EventProcessorWithJsonOutput; -use experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; +use event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use serde_json::Value; use std::io::IsTerminal; @@ -59,7 +57,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any color, last_message_file, json: json_mode, - experimental_json, sandbox_mode: sandbox_mode_cli_arg, prompt, output_schema: output_schema_path, @@ -212,17 +209,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let _ = tracing_subscriber::registry().with(fmt_layer).try_init(); } - let mut event_processor: Box = match (json_mode, experimental_json) { - (_, true) => Box::new(ExperimentalEventProcessorWithJsonOutput::new( - last_message_file.clone(), - )), - (true, _) => { - eprintln!( - "The existing `--json` output format is being deprecated. Please try the new format using `--experimental-json`." - ); - - Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())) - } + let mut event_processor: Box = match json_mode { + true => Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())), _ => Box::new(EventProcessorWithHumanOutput::create_with_ansi( stdout_with_ansi, &config, diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index c462879767..9cbff57943 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -12,6 +12,8 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::WebSearchEndEvent; +use codex_exec::event_processor_with_jsonl_output::EventProcessorWithJsonOutput; use codex_exec::exec_events::AssistantMessageItem; use codex_exec::exec_events::CommandExecutionItem; use codex_exec::exec_events::CommandExecutionStatus; @@ -34,7 +36,7 @@ use codex_exec::exec_events::TurnCompletedEvent; use codex_exec::exec_events::TurnFailedEvent; use codex_exec::exec_events::TurnStartedEvent; use codex_exec::exec_events::Usage; -use codex_exec::experimental_event_processor_with_json_output::ExperimentalEventProcessorWithJsonOutput; +use codex_exec::exec_events::WebSearchItem; use mcp_types::CallToolResult; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -49,11 +51,10 @@ fn event(id: &str, msg: EventMsg) -> Event { #[test] fn session_configured_produces_thread_started_event() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); - let session_id = codex_protocol::mcp_protocol::ConversationId::from_string( - "67e55044-10b1-426f-9247-bb680e5fe0c8", - ) - .unwrap(); + let mut ep = EventProcessorWithJsonOutput::new(None); + let session_id = + codex_protocol::ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .unwrap(); let rollout_path = PathBuf::from("/tmp/rollout.json"); let ev = event( "e1", @@ -78,7 +79,7 @@ fn session_configured_produces_thread_started_event() { #[test] fn task_started_produces_turn_started_event() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "t1", EventMsg::TaskStarted(codex_core::protocol::TaskStartedEvent { @@ -89,13 +90,36 @@ fn task_started_produces_turn_started_event() { assert_eq!(out, vec![ThreadEvent::TurnStarted(TurnStartedEvent {})]); } +#[test] +fn web_search_end_emits_item_completed() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let query = "rust async await".to_string(); + let out = ep.collect_thread_events(&event( + "w1", + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: "call-123".to_string(), + query: query.clone(), + }), + )); + + assert_eq!( + out, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::WebSearch(WebSearchItem { query }), + }, + })] + ); +} + #[test] fn plan_update_emits_todo_list_started_updated_and_completed() { use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // First plan update => item.started (todo_list) let first = event( @@ -212,7 +236,7 @@ fn plan_update_emits_todo_list_started_updated_and_completed() { #[test] fn mcp_tool_call_begin_and_end_emit_item_events() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let invocation = McpInvocation { server: "server_a".to_string(), tool: "tool_x".to_string(), @@ -272,7 +296,7 @@ fn mcp_tool_call_begin_and_end_emit_item_events() { #[test] fn mcp_tool_call_failure_sets_failed_status() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let invocation = McpInvocation { server: "server_b".to_string(), tool: "tool_y".to_string(), @@ -319,7 +343,7 @@ fn plan_update_after_complete_starts_new_todo_list_with_new_id() { use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // First turn: start + complete let start = event( @@ -364,7 +388,7 @@ fn plan_update_after_complete_starts_new_todo_list_with_new_id() { #[test] fn agent_reasoning_produces_item_completed_reasoning() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let ev = event( "e1", EventMsg::AgentReasoning(AgentReasoningEvent { @@ -387,7 +411,7 @@ fn agent_reasoning_produces_item_completed_reasoning() { #[test] fn agent_message_produces_item_completed_assistant_message() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let ev = event( "e1", EventMsg::AgentMessage(AgentMessageEvent { @@ -410,7 +434,7 @@ fn agent_message_produces_item_completed_assistant_message() { #[test] fn error_event_produces_error() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "e1", EventMsg::Error(codex_core::protocol::ErrorEvent { @@ -427,7 +451,7 @@ fn error_event_produces_error() { #[test] fn stream_error_event_produces_error() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let out = ep.collect_thread_events(&event( "e1", EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { @@ -444,7 +468,7 @@ fn stream_error_event_produces_error() { #[test] fn error_followed_by_task_complete_produces_turn_failed() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let error_event = event( "e1", @@ -477,7 +501,7 @@ fn error_followed_by_task_complete_produces_turn_failed() { #[test] fn exec_command_end_success_produces_completed_command_item() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // Begin -> no output let begin = event( @@ -537,7 +561,7 @@ fn exec_command_end_success_produces_completed_command_item() { #[test] fn exec_command_end_failure_produces_failed_command_item() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // Begin -> no output let begin = event( @@ -596,7 +620,7 @@ fn exec_command_end_failure_produces_failed_command_item() { #[test] fn exec_command_end_without_begin_is_ignored() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // End event arrives without a prior Begin; should produce no thread events. let end_only = event( @@ -617,7 +641,7 @@ fn exec_command_end_without_begin_is_ignored() { #[test] fn patch_apply_success_produces_item_completed_patchapply() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // Prepare a patch with multiple kinds of changes let mut changes = std::collections::HashMap::new(); @@ -699,7 +723,7 @@ fn patch_apply_success_produces_item_completed_patchapply() { #[test] fn patch_apply_failure_produces_item_completed_patchapply_failed() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); let mut changes = std::collections::HashMap::new(); changes.insert( @@ -753,7 +777,7 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() { #[test] fn task_complete_produces_turn_completed_with_usage() { - let mut ep = ExperimentalEventProcessorWithJsonOutput::new(None); + let mut ep = EventProcessorWithJsonOutput::new(None); // First, feed a TokenCount event with known totals. let usage = codex_core::protocol::TokenUsage { diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index e2a693e9ac..563515c637 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -10,7 +10,7 @@ workspace = true base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-core = { workspace = true } -codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } rand = { workspace = true } reqwest = { workspace = true, features = ["json", "blocking"] } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 0d04a61337..385a97fd43 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -148,15 +148,14 @@ fn print_colored_warning_device_code() { /// Full device code login flow. pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { let client = reqwest::Client::new(); - let auth_base_url = opts.issuer.trim_end_matches('/').to_owned(); + let auth_base_url = format!("{}/api/accounts", opts.issuer.trim_end_matches('/')); print_colored_warning_device_code(); println!("⏳ Generating a new 9-digit device code for authentication...\n"); let uc = request_user_code(&client, &auth_base_url, &opts.client_id).await?; println!( "To authenticate, visit: {}/deviceauth/authorize and enter code: {}", - opts.issuer.trim_end_matches('/'), - uc.user_code + auth_base_url, uc.user_code ); let code_resp = poll_for_token( diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index d5e5836f0f..df4fc1e76a 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -9,6 +9,7 @@ pub use server::ShutdownHandle; pub use server::run_login_server; // Re-export commonly used auth types and helpers from codex-core for compatibility +pub use codex_app_server_protocol::AuthMode; pub use codex_core::AuthManager; pub use codex_core::CodexAuth; pub use codex_core::auth::AuthDotJson; @@ -20,4 +21,3 @@ pub use codex_core::auth::logout; pub use codex_core::auth::try_read_auth_json; pub use codex_core::auth::write_auth_json; pub use codex_core::token_data::TokenData; -pub use codex_protocol::mcp_protocol::AuthMode; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 0b63b98a6d..4bd5e2e36b 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -32,7 +32,7 @@ fn make_jwt(payload: serde_json::Value) -> String { async fn mock_usercode_success(server: &MockServer) { Mock::given(method("POST")) - .and(path("/deviceauth/usercode")) + .and(path("/api/accounts/deviceauth/usercode")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "device_auth_id": "device-auth-123", "user_code": "CODE-12345", @@ -45,7 +45,7 @@ async fn mock_usercode_success(server: &MockServer) { async fn mock_usercode_failure(server: &MockServer, status: u16) { Mock::given(method("POST")) - .and(path("/deviceauth/usercode")) + .and(path("/api/accounts/deviceauth/usercode")) .respond_with(ResponseTemplate::new(status)) .mount(server) .await; @@ -58,7 +58,7 @@ async fn mock_poll_token_two_step( ) { let c = counter.clone(); Mock::given(method("POST")) - .and(path("/deviceauth/token")) + .and(path("/api/accounts/deviceauth/token")) .respond_with(move |_: &Request| { let attempt = c.fetch_add(1, Ordering::SeqCst); if attempt == 0 { @@ -214,7 +214,7 @@ async fn device_code_login_integration_handles_error_payload() { // // /deviceauth/token → returns error payload with status 401 mock_poll_token_single( &mock_server, - "/deviceauth/token", + "/api/accounts/deviceauth/token", ResponseTemplate::new(401).set_body_json(json!({ "error": "authorization_declined", "error_description": "Denied" diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index db48da28e2..bcbfdf4471 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -22,7 +22,7 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::Submission; use codex_core::protocol::TaskCompleteEvent; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::RequestId; diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index addc9572ec..039375b05d 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -7,7 +7,7 @@ use crate::codex_tool_config::create_tool_for_codex_tool_call_param; use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::outgoing_message::OutgoingMessageSender; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_core::AuthManager; use codex_core::ConversationManager; diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 2e350dd9a9..4b6782d8de 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -234,8 +234,8 @@ mod tests { use anyhow::Result; use codex_core::protocol::EventMsg; use codex_core::protocol::SessionConfiguredEvent; + use codex_protocol::ConversationId; use codex_protocol::config_types::ReasoningEffort; - use codex_protocol::mcp_protocol::ConversationId; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; diff --git a/codex-rs/otel/Cargo.toml b/codex-rs/otel/Cargo.toml index 610423f9cc..ea518c2e4f 100644 --- a/codex-rs/otel/Cargo.toml +++ b/codex-rs/otel/Cargo.toml @@ -4,9 +4,9 @@ name = "codex-otel" version = { workspace = true } [lib] +doctest = false name = "codex_otel" path = "src/lib.rs" -doctest = false [lints] workspace = true @@ -15,25 +15,30 @@ workspace = true # Compile-time gate for OTLP support; disabled by default. # Downstream crates can enable via `features = ["otel"]`. default = [] -otel = [ - "opentelemetry", - "opentelemetry_sdk", - "opentelemetry-otlp", - "tonic", -] +otel = ["opentelemetry", "opentelemetry_sdk", "opentelemetry-otlp", "tonic"] [dependencies] -codex-protocol = { path = "../protocol" } chrono = { workspace = true } -tracing = { workspace = true } -opentelemetry = { workspace = true, features = ["logs"], optional = true } -opentelemetry_sdk = { workspace = true, features = ["logs", "rt-tokio"], optional = true } -opentelemetry-otlp = { workspace = true, features = ["grpc-tonic", "http-proto", "http-json", "reqwest", "reqwest-rustls"], optional = true } -opentelemetry-semantic-conventions = { workspace = true } -tonic = { workspace = true, optional = true } -serde = { workspace = true, features = ["derive"] } -strum_macros = { workspace = true } -reqwest = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-protocol = { workspace = true } eventsource-stream = { workspace = true } +opentelemetry = { workspace = true, features = ["logs"], optional = true } +opentelemetry-otlp = { workspace = true, features = [ + "grpc-tonic", + "http-proto", + "http-json", + "reqwest", + "reqwest-rustls", +], optional = true } +opentelemetry-semantic-conventions = { workspace = true } +opentelemetry_sdk = { workspace = true, features = [ + "logs", + "rt-tokio", +], optional = true } +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +strum_macros = { workspace = true } tokio = { workspace = true } -serde_json = { workspace = true } \ No newline at end of file +tonic = { workspace = true, optional = true } +tracing = { workspace = true } diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 87ed6bb535..3e2ffeb7af 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -1,9 +1,9 @@ use chrono::SecondsFormat; use chrono::Utc; +use codex_app_server_protocol::AuthMode; +use codex_protocol::ConversationId; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InputItem; diff --git a/codex-rs/protocol-ts/Cargo.toml b/codex-rs/protocol-ts/Cargo.toml index 3a0233dec3..53f314ccbe 100644 --- a/codex-rs/protocol-ts/Cargo.toml +++ b/codex-rs/protocol-ts/Cargo.toml @@ -16,7 +16,6 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } -mcp-types = { workspace = true } -codex-protocol = { workspace = true } -ts-rs = { workspace = true } clap = { workspace = true, features = ["derive"] } +codex-app-server-protocol = { workspace = true } +ts-rs = { workspace = true } diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 848aeb25c7..224fdd2d07 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -1,6 +1,12 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::export_client_responses; +use codex_app_server_protocol::export_server_responses; use std::ffi::OsStr; use std::fs; use std::io::Read; @@ -15,44 +21,17 @@ const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { ensure_dir(out_dir)?; - // Generate TS bindings - mcp_types::InitializeResult::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ConversationId::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::InputItem::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ClientRequest::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ServerRequest::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::InitializeResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::NewConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ListConversationsResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ResumeConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ArchiveConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::AddConversationSubscriptionResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::RemoveConversationSubscriptionResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::SendUserMessageResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::SendUserTurnResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::InterruptConversationResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GitDiffToRemoteResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::LoginApiKeyParams::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::LoginApiKeyResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::LoginChatGptResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::CancelLoginChatGptResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::LogoutChatGptResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GetAuthStatusResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::FuzzyFileSearchParams::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::FuzzyFileSearchResult::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::FuzzyFileSearchResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GetUserSavedConfigResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::SetDefaultModelResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::GetUserAgentResponse::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::UserInfoResponse::export_all_to(out_dir)?; + // Generate the TS bindings client -> server messages. + ClientRequest::export_all_to(out_dir)?; + export_client_responses(out_dir)?; + ClientNotification::export_all_to(out_dir)?; - // All notification types reachable from this enum will be generated by - // induction, so they do not need to be listed individually. - codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; - codex_protocol::mcp_protocol::ClientNotification::export_all_to(out_dir)?; + // Generate the TS bindings server -> client messages. + ServerRequest::export_all_to(out_dir)?; + export_server_responses(out_dir)?; + ServerNotification::export_all_to(out_dir)?; + // Generate index.ts that re-exports all types. generate_index_ts(out_dir)?; // Prepend header to each generated .ts file diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 294f9982f7..72cf984e96 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -32,7 +32,6 @@ uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] anyhow = { workspace = true } -pretty_assertions = { workspace = true } tempfile = { workspace = true } [package.metadata.cargo-shear] diff --git a/codex-rs/protocol/src/conversation_id.rs b/codex-rs/protocol/src/conversation_id.rs new file mode 100644 index 0000000000..687237e0ab --- /dev/null +++ b/codex-rs/protocol/src/conversation_id.rs @@ -0,0 +1,68 @@ +use std::fmt::Display; + +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] +pub struct ConversationId { + uuid: Uuid, +} + +impl ConversationId { + pub fn new() -> Self { + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) + } +} + +impl Default for ConversationId { + fn default() -> Self { + Self::new() + } +} + +impl Display for ConversationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.uuid) + } +} + +impl Serialize for ConversationId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) + } +} + +impl<'de> Deserialize<'de> for ConversationId { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + let uuid = Uuid::parse_str(&value).map_err(serde::de::Error::custom)?; + Ok(Self { uuid }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_conversation_id_default_is_not_zeroes() { + let id = ConversationId::default(); + assert_ne!(id.uuid, Uuid::nil()); + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 7f746f25b7..11ab0b3fd6 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,6 +1,7 @@ +mod conversation_id; +pub use conversation_id::ConversationId; pub mod config_types; pub mod custom_prompts; -pub mod mcp_protocol; pub mod message_history; pub mod models; pub mod num_format; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 01b4eb3a5a..98e218b962 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -10,10 +10,10 @@ use std::path::PathBuf; use std::str::FromStr; use std::time::Duration; +use crate::ConversationId; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; -use crate::mcp_protocol::ConversationId; use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2e89288b02..21472d8fe9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -39,6 +39,7 @@ codex-git-tooling = { workspace = true } codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } +codex-app-server-protocol = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 9c30afdb4b..2d840a8c2c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -16,7 +16,7 @@ use codex_core::config::persist_model_selection; use codex_core::model_family::find_family_for_model; use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -451,7 +451,7 @@ mod tests { use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::protocol::SessionConfiguredEvent; - use codex_protocol::mcp_protocol::ConversationId; + use codex_protocol::ConversationId; use ratatui::prelude::Line; use std::path::PathBuf; use std::sync::Arc; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cf03784d46..b5c1300b45 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -9,7 +9,7 @@ use crate::pager_overlay::Overlay; use crate::tui; use crate::tui::TuiEvent; use codex_core::protocol::ConversationPathResponseEvent; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index ec231437b0..34b73d676e 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -32,6 +32,8 @@ use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; use crate::bottom_pane::prompt_args::parse_slash_name; +use crate::bottom_pane::prompt_args::prompt_argument_names; +use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders; use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders; use crate::slash_command::SlashCommand; use crate::style::user_message_style; @@ -45,6 +47,7 @@ use crate::bottom_pane::textarea::TextArea; use crate::bottom_pane::textarea::TextAreaState; use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; +use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; use codex_file_search::FileMatch; use std::cell::RefCell; @@ -72,6 +75,16 @@ struct AttachedImage { path: PathBuf, } +enum PromptSelectionMode { + Completion, + Submit, +} + +enum PromptSelectionAction { + Insert { text: String, cursor: Option }, + Submit { text: String }, +} + pub(crate) struct ChatComposer { textarea: TextArea, textarea_state: RefCell, @@ -449,17 +462,17 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let name = prompt.name.clone(); - let starts_with_cmd = first_line - .trim_start() - .starts_with(format!("/{PROMPTS_CMD_PREFIX}:{name}").as_str()); - if !starts_with_cmd { - self.textarea.set_text( - format!("/{PROMPTS_CMD_PREFIX}:{name} ").as_str(), - ); - } - if !self.textarea.text().is_empty() { - cursor_target = Some(self.textarea.text().len()); + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Completion, + ) { + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + cursor_target = Some(target); + } + PromptSelectionAction::Submit { .. } => {} } } } @@ -497,28 +510,21 @@ impl ChatComposer { } CommandItem::UserPrompt(idx) => { if let Some(prompt) = popup.prompt(idx) { - let has_numeric = prompt_has_numeric_placeholders(&prompt.content); - - if !has_numeric { - // No placeholders at all: auto-submit the literal content - self.textarea.set_text(""); - return (InputResult::Submitted(prompt.content.clone()), true); - } - // Numeric placeholders present. - // If the user already typed positional args on the first line, - // expand immediately and submit; otherwise insert "/name " so - // they can type args. - let first_line = self.textarea.text().lines().next().unwrap_or(""); - if let Some(expanded) = - expand_if_numeric_with_positional_args(prompt, first_line) - { - self.textarea.set_text(""); - return (InputResult::Submitted(expanded), true); - } else { - let name = prompt.name.clone(); - let text = format!("/{PROMPTS_CMD_PREFIX}:{name} "); - self.textarea.set_text(&text); - self.textarea.set_cursor(self.textarea.text().len()); + match prompt_selection_action( + prompt, + first_line, + PromptSelectionMode::Submit, + ) { + PromptSelectionAction::Submit { text } => { + self.textarea.set_text(""); + return (InputResult::Submitted(text), true); + } + PromptSelectionAction::Insert { text, cursor } => { + let target = cursor.unwrap_or(text.len()); + self.textarea.set_text(&text); + self.textarea.set_cursor(target); + return (InputResult::None, true); + } } } return (InputResult::None, true); @@ -932,6 +938,7 @@ impl ChatComposer { return (InputResult::None, true); } let mut text = self.textarea.text().to_string(); + let original_input = text.clone(); self.textarea.set_text(""); // Replace all pending pastes in the text @@ -945,13 +952,20 @@ impl ChatComposer { // If there is neither text nor attachments, suppress submission entirely. let has_attachments = !self.attached_images.is_empty(); text = text.trim().to_string(); - - if let Some(expanded) = - expand_custom_prompt(&text, &self.custom_prompts).unwrap_or_default() - { + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { + Ok(expanded) => expanded, + Err(err) => { + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(err.user_message()), + ))); + self.textarea.set_text(&original_input); + self.textarea.set_cursor(original_input.len()); + return (InputResult::None, true); + } + }; + if let Some(expanded) = expanded_prompt { text = expanded; } - if text.is_empty() && !has_attachments { return (InputResult::None, true); } @@ -1513,6 +1527,54 @@ impl WidgetRef for ChatComposer { } } +fn prompt_selection_action( + prompt: &CustomPrompt, + first_line: &str, + mode: PromptSelectionMode, +) -> PromptSelectionAction { + let named_args = prompt_argument_names(&prompt.content); + let has_numeric = prompt_has_numeric_placeholders(&prompt.content); + + match mode { + PromptSelectionMode::Completion => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name); + PromptSelectionAction::Insert { text, cursor: None } + } + PromptSelectionMode::Submit => { + if !named_args.is_empty() { + let (text, cursor) = + prompt_command_with_arg_placeholders(&prompt.name, &named_args); + return PromptSelectionAction::Insert { + text, + cursor: Some(cursor), + }; + } + if has_numeric { + if let Some(expanded) = expand_if_numeric_with_positional_args(prompt, first_line) { + return PromptSelectionAction::Submit { text: expanded }; + } + let text = format!("/{PROMPTS_CMD_PREFIX}:{} ", prompt.name); + return PromptSelectionAction::Insert { text, cursor: None }; + } + PromptSelectionAction::Submit { + text: prompt.content.clone(), + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -1528,7 +1590,6 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::AttachedImage; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; - use crate::bottom_pane::footer::footer_height; use crate::bottom_pane::prompt_args::extract_positional_args_for_prompt_line; use crate::bottom_pane::textarea::TextArea; use tokio::sync::mpsc::unbounded_channel; @@ -2666,6 +2727,174 @@ mod tests { assert!(composer.textarea.is_empty()); } + #[test] + fn custom_prompt_submission_expands_arguments() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice BRANCH=main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Review Alice changes on main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_submission_accepts_quoted_values() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!( + InputResult::Submitted("Pair Alice Smith with dev-main".to_string()), + result + ); + assert!(composer.textarea.is_empty()); + } + + #[test] + fn custom_prompt_invalid_args_reports_error() { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]); + + composer + .textarea + .set_text("/prompts:my-prompt USER=Alice stray"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!( + "/prompts:my-prompt USER=Alice stray", + composer.textarea.text() + ); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.contains("expected key=value")); + found_error = true; + break; + } + } + assert!(found_error, "expected error history cell to be sent"); + } + + #[test] + fn custom_prompt_missing_required_args_reports_error() { + let (tx, mut rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]); + + // Provide only one of the required args + composer.textarea.set_text("/prompts:my-prompt USER=Alice"); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::None, result); + assert_eq!("/prompts:my-prompt USER=Alice", composer.textarea.text()); + + let mut found_error = false; + while let Ok(event) = rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let message = cell + .display_lines(80) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(message.to_lowercase().contains("missing required args")); + assert!(message.contains("BRANCH")); + found_error = true; + break; + } + } + assert!( + found_error, + "expected missing args error history cell to be sent" + ); + } + #[test] fn selecting_custom_prompt_with_args_expands_placeholders() { // Support $1..$9 and $ARGUMENTS in prompt content. @@ -2704,6 +2933,37 @@ mod tests { assert_eq!(InputResult::Submitted(expected), result); } + #[test] + fn numeric_prompt_positional_args_does_not_error() { + // Ensure that a prompt with only numeric placeholders does not trigger + // key=value parsing errors when given positional arguments. + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + composer.set_custom_prompts(vec![CustomPrompt { + name: "elegant".to_string(), + path: "/tmp/elegant.md".to_string().into(), + content: "Echo: $ARGUMENTS".to_string(), + description: None, + argument_hint: None, + }]); + + // Type positional args; should submit with numeric expansion, no errors. + composer.textarea.set_text("/prompts:elegant hi"); + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted("Echo: hi".to_string()), result); + assert!(composer.textarea.is_empty()); + } + #[test] fn selecting_custom_prompt_with_no_args_inserts_template() { let prompt_text = "X:$1 Y:$2 All:[$ARGUMENTS]"; diff --git a/codex-rs/tui/src/bottom_pane/prompt_args.rs b/codex-rs/tui/src/bottom_pane/prompt_args.rs index 51c95d9998..48c3cedfab 100644 --- a/codex-rs/tui/src/bottom_pane/prompt_args.rs +++ b/codex-rs/tui/src/bottom_pane/prompt_args.rs @@ -1,6 +1,60 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; +use lazy_static::lazy_static; +use regex_lite::Regex; use shlex::Shlex; +use std::collections::HashMap; +use std::collections::HashSet; + +lazy_static! { + static ref PROMPT_ARG_REGEX: Regex = + Regex::new(r"\$[A-Z][A-Z0-9_]*").unwrap_or_else(|_| std::process::abort()); +} + +#[derive(Debug)] +pub enum PromptArgsError { + MissingAssignment { token: String }, + MissingKey { token: String }, +} + +impl PromptArgsError { + fn describe(&self, command: &str) -> String { + match self { + PromptArgsError::MissingAssignment { token } => format!( + "Could not parse {command}: expected key=value but found '{token}'. Wrap values in double quotes if they contain spaces." + ), + PromptArgsError::MissingKey { token } => { + format!("Could not parse {command}: expected a name before '=' in '{token}'.") + } + } + } +} + +#[derive(Debug)] +pub enum PromptExpansionError { + Args { + command: String, + error: PromptArgsError, + }, + MissingArgs { + command: String, + missing: Vec, + }, +} + +impl PromptExpansionError { + pub fn user_message(&self) -> String { + match self { + PromptExpansionError::Args { command, error } => error.describe(command), + PromptExpansionError::MissingArgs { command, missing } => { + let list = missing.join(", "); + format!( + "Missing required args for {command}: {list}. Provide as key=value (quote values with spaces)." + ) + } + } + } +} /// Parse a first-line slash command of the form `/name `. /// Returns `(name, rest_after_name)` if the line begins with `/` and contains @@ -27,6 +81,54 @@ pub fn parse_positional_args(rest: &str) -> Vec { Shlex::new(rest).collect() } +/// Extracts the unique placeholder variable names from a prompt template. +/// +/// A placeholder is any token that matches the pattern `$[A-Z][A-Z0-9_]*` +/// (for example `$USER`). The function returns the variable names without +/// the leading `$`, de-duplicated and in the order of first appearance. +pub fn prompt_argument_names(content: &str) -> Vec { + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for m in PROMPT_ARG_REGEX.find_iter(content) { + if m.start() > 0 && content.as_bytes()[m.start() - 1] == b'$' { + continue; + } + let name = &content[m.start() + 1..m.end()]; + // Exclude special positional aggregate token from named args. + if name == "ARGUMENTS" { + continue; + } + let name = name.to_string(); + if seen.insert(name.clone()) { + names.push(name); + } + } + names +} + +/// Parses the `key=value` pairs that follow a custom prompt name. +/// +/// The input is split using shlex rules, so quoted values are supported +/// (for example `USER="Alice Smith"`). The function returns a map of parsed +/// arguments, or an error if a token is missing `=` or if the key is empty. +pub fn parse_prompt_inputs(rest: &str) -> Result, PromptArgsError> { + let mut map = HashMap::new(); + if rest.trim().is_empty() { + return Ok(map); + } + + for token in Shlex::new(rest) { + let Some((key, value)) = token.split_once('=') else { + return Err(PromptArgsError::MissingAssignment { token }); + }; + if key.is_empty() { + return Err(PromptArgsError::MissingKey { token }); + } + map.insert(key.to_string(), value.to_string()); + } + Ok(map) +} + /// Expands a message of the form `/prompts:name [value] [value] …` using a matching saved prompt. /// /// If the text does not start with `/prompts:`, or if no prompt named `name` exists, @@ -35,7 +137,7 @@ pub fn parse_positional_args(rest: &str) -> Vec { pub fn expand_custom_prompt( text: &str, custom_prompts: &[CustomPrompt], -) -> Result, ()> { +) -> Result, PromptExpansionError> { let Some((name, rest)) = parse_slash_name(text) else { return Ok(None); }; @@ -49,14 +151,45 @@ pub fn expand_custom_prompt( Some(prompt) => prompt, None => return Ok(None), }; - // Only support numeric placeholders ($1..$9) and $ARGUMENTS. - if prompt_has_numeric_placeholders(&prompt.content) { - let pos_args: Vec = Shlex::new(rest).collect(); - let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); - return Ok(Some(expanded)); + // If there are named placeholders, expect key=value inputs. + let required = prompt_argument_names(&prompt.content); + if !required.is_empty() { + let inputs = parse_prompt_inputs(rest).map_err(|error| PromptExpansionError::Args { + command: format!("/{name}"), + error, + })?; + let missing: Vec = required + .into_iter() + .filter(|k| !inputs.contains_key(k)) + .collect(); + if !missing.is_empty() { + return Err(PromptExpansionError::MissingArgs { + command: format!("/{name}"), + missing, + }); + } + let content = &prompt.content; + let replaced = PROMPT_ARG_REGEX.replace_all(content, |caps: ®ex_lite::Captures<'_>| { + if let Some(matched) = caps.get(0) + && matched.start() > 0 + && content.as_bytes()[matched.start() - 1] == b'$' + { + return matched.as_str().to_string(); + } + let whole = &caps[0]; + let key = &whole[1..]; + inputs + .get(key) + .cloned() + .unwrap_or_else(|| whole.to_string()) + }); + return Ok(Some(replaced.into_owned())); } - // No recognized placeholders: return the literal content. - Ok(Some(prompt.content.clone())) + + // Otherwise, treat it as numeric/positional placeholder prompt (or none). + let pos_args: Vec = Shlex::new(rest).collect(); + let expanded = expand_numeric_placeholders(&prompt.content, &pos_args); + Ok(Some(expanded)) } /// Detect whether `content` contains numeric placeholders ($1..$9) or `$ARGUMENTS`. @@ -107,6 +240,9 @@ pub fn expand_if_numeric_with_positional_args( prompt: &CustomPrompt, first_line: &str, ) -> Option { + if !prompt_argument_names(&prompt.content).is_empty() { + return None; + } if !prompt_has_numeric_placeholders(&prompt.content) { return None; } @@ -159,3 +295,112 @@ pub fn expand_numeric_placeholders(content: &str, args: &[String]) -> String { out.push_str(&content[i..]); out } + +/// Constructs a command text for a custom prompt with arguments. +/// Returns the text and the cursor position (inside the first double quote). +pub fn prompt_command_with_arg_placeholders(name: &str, args: &[String]) -> (String, usize) { + let mut text = format!("/{PROMPTS_CMD_PREFIX}:{name}"); + let mut cursor: usize = text.len(); + for (i, arg) in args.iter().enumerate() { + text.push_str(format!(" {arg}=\"\"").as_str()); + if i == 0 { + cursor = text.len() - 1; // inside first "" + } + } + (text, cursor) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expand_arguments_basic() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = + expand_custom_prompt("/prompts:my-prompt USER=Alice BRANCH=main", &prompts).unwrap(); + assert_eq!(out, Some("Review Alice changes on main".to_string())); + } + + #[test] + fn quoted_values_ok() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Pair $USER with $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt( + "/prompts:my-prompt USER=\"Alice Smith\" BRANCH=dev-main", + &prompts, + ) + .unwrap(); + assert_eq!(out, Some("Pair Alice Smith with dev-main".to_string())); + } + + #[test] + fn invalid_arg_token_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice stray", &prompts) + .unwrap_err() + .user_message(); + assert!(err.contains("expected key=value")); + } + + #[test] + fn missing_required_args_reports_error() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "Review $USER changes on $BRANCH".to_string(), + description: None, + argument_hint: None, + }]; + let err = expand_custom_prompt("/prompts:my-prompt USER=Alice", &prompts) + .unwrap_err() + .user_message(); + assert!(err.to_lowercase().contains("missing required args")); + assert!(err.contains("BRANCH")); + } + + #[test] + fn escaped_placeholder_is_ignored() { + assert_eq!( + prompt_argument_names("literal $$USER"), + Vec::::new() + ); + assert_eq!( + prompt_argument_names("literal $$USER and $REAL"), + vec!["REAL".to_string()] + ); + } + + #[test] + fn escaped_placeholder_remains_literal() { + let prompts = vec![CustomPrompt { + name: "my-prompt".to_string(), + path: "/tmp/my-prompt.md".to_string().into(), + content: "literal $$USER".to_string(), + description: None, + argument_hint: None, + }]; + + let out = expand_custom_prompt("/prompts:my-prompt", &prompts).unwrap(); + assert_eq!(out, Some("literal $$USER".to_string())); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 981e0dfa94..fecfd8e01f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -41,7 +41,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::protocol::UserMessageEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use codex_protocol::parse_command::ParsedCommand; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -256,6 +256,8 @@ pub(crate) struct ChatWidget { ghost_snapshots_disabled: bool, // Whether to add a final message separator after the last message needs_final_message_separator: bool, + + last_rendered_width: std::cell::Cell>, } struct UserMessage { @@ -658,7 +660,10 @@ impl ChatWidget { self.add_to_history(history_cell::FinalMessageSeparator::new(elapsed_seconds)); self.needs_final_message_separator = false; } - self.stream_controller = Some(StreamController::new(self.config.clone())); + self.stream_controller = Some(StreamController::new( + self.config.clone(), + self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + )); } if let Some(controller) = self.stream_controller.as_mut() && controller.push(&delta) @@ -912,6 +917,7 @@ impl ChatWidget { ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), } } @@ -974,6 +980,7 @@ impl ChatWidget { ghost_snapshots: Vec::new(), ghost_snapshots_disabled: true, needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), } } @@ -1447,7 +1454,7 @@ impl ChatWidget { } else { // Show explanation when there are no structured findings. let mut rendered: Vec> = vec!["".into()]; - append_markdown(&explanation, &mut rendered, &self.config); + append_markdown(&explanation, None, &mut rendered, &self.config); let body_cell = AgentMessageCell::new(rendered, false); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); @@ -1456,7 +1463,7 @@ impl ChatWidget { let message_text = codex_core::review_format::format_review_findings_block(&output.findings, None); let mut message_lines: Vec> = Vec::new(); - append_markdown(&message_text, &mut message_lines, &self.config); + append_markdown(&message_text, None, &mut message_lines, &self.config); let body_cell = AgentMessageCell::new(message_lines, true); self.app_event_tx .send(AppEvent::InsertHistoryCell(Box::new(body_cell))); @@ -1998,6 +2005,7 @@ impl WidgetRef for &ChatWidget { tool.render_ref(area, buf); } } + self.last_rendered_width.set(Some(area.width as usize)); } } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f3799ad336..34e8e3bcbe 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -34,7 +34,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; @@ -341,6 +341,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots: Vec::new(), ghost_snapshots_disabled: false, needs_final_message_separator: false, + last_rendered_width: std::cell::Cell::new(None), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 5db6ebe1a7..6a1ac37a36 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -6,6 +6,7 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::markdown::MarkdownCitationContext; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; @@ -128,38 +129,44 @@ impl HistoryCell for UserHistoryCell { #[derive(Debug)] pub(crate) struct ReasoningSummaryCell { - _header: Vec>, - content: Vec>, + _header: String, + content: String, + citation_context: MarkdownCitationContext, } impl ReasoningSummaryCell { - pub(crate) fn new(header: Vec>, content: Vec>) -> Self { + pub(crate) fn new( + header: String, + content: String, + citation_context: MarkdownCitationContext, + ) -> Self { Self { _header: header, content, + citation_context, } } } impl HistoryCell for ReasoningSummaryCell { fn display_lines(&self, width: u16) -> Vec> { - let summary_lines = self - .content - .iter() - .map(|line| { - Line::from( - line.spans - .iter() - .map(|span| { - Span::styled( - span.content.clone().into_owned(), - span.style - .add_modifier(Modifier::ITALIC) - .add_modifier(Modifier::DIM), - ) - }) - .collect::>(), - ) + let mut lines: Vec> = Vec::new(); + append_markdown( + &self.content, + Some((width as usize).saturating_sub(2)), + &mut lines, + self.citation_context.clone(), + ); + let summary_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); + let summary_lines = lines + .into_iter() + .map(|mut line| { + line.spans = line + .spans + .into_iter() + .map(|span| span.patch_style(summary_style)) + .collect(); + line }) .collect::>(); @@ -174,7 +181,14 @@ impl HistoryCell for ReasoningSummaryCell { fn transcript_lines(&self) -> Vec> { let mut out: Vec> = Vec::new(); out.push("thinking".magenta().bold().into()); - out.extend(self.content.clone()); + let mut lines = Vec::new(); + append_markdown( + &self.content, + None, + &mut lines, + self.citation_context.clone(), + ); + out.extend(lines); out } } @@ -1065,7 +1079,7 @@ pub(crate) fn new_reasoning_block( ) -> TranscriptOnlyHistoryCell { let mut lines: Vec> = Vec::new(); lines.push(Line::from("thinking".magenta().italic())); - append_markdown(&full_reasoning_buffer, &mut lines, config); + append_markdown(&full_reasoning_buffer, None, &mut lines, config); TranscriptOnlyHistoryCell { lines } } @@ -1089,14 +1103,12 @@ pub(crate) fn new_reasoning_summary_block( // then we don't have a summary to inject into history if after_close_idx < full_reasoning_buffer.len() { let header_buffer = full_reasoning_buffer[..after_close_idx].to_string(); - let mut header_lines = Vec::new(); - append_markdown(&header_buffer, &mut header_lines, config); - let summary_buffer = full_reasoning_buffer[after_close_idx..].to_string(); - let mut summary_lines = Vec::new(); - append_markdown(&summary_buffer, &mut summary_lines, config); - - return Box::new(ReasoningSummaryCell::new(header_lines, summary_lines)); + return Box::new(ReasoningSummaryCell::new( + header_buffer, + summary_buffer, + config.into(), + )); } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1d66251ff0..8130d64796 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -5,6 +5,7 @@ #![deny(clippy::disallowed_methods)] use app::App; pub use app::AppExitInfo; +use codex_app_server_protocol::AuthMode; use codex_core::AuthManager; use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID; use codex_core::CodexAuth; @@ -19,7 +20,6 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; -use codex_protocol::mcp_protocol::AuthMode; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; use std::fs::OpenOptions; use std::path::PathBuf; diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 010ff78ab5..5ec3a27f84 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -2,17 +2,47 @@ use codex_core::config::Config; use codex_core::config_types::UriBasedFileOpener; use ratatui::text::Line; use std::path::Path; +use std::path::PathBuf; -pub(crate) fn append_markdown( - markdown_source: &str, - lines: &mut Vec>, - config: &Config, -) { - append_markdown_with_opener_and_cwd(markdown_source, lines, config.file_opener, &config.cwd); +#[derive(Clone, Debug)] +pub struct MarkdownCitationContext { + file_opener: UriBasedFileOpener, + cwd: PathBuf, } -fn append_markdown_with_opener_and_cwd( +impl MarkdownCitationContext { + pub(crate) fn new(file_opener: UriBasedFileOpener, cwd: PathBuf) -> Self { + Self { file_opener, cwd } + } +} + +impl From<&Config> for MarkdownCitationContext { + fn from(config: &Config) -> Self { + MarkdownCitationContext::new(config.file_opener, config.cwd.clone()) + } +} + +pub(crate) fn append_markdown( markdown_source: &str, + width: Option, + lines: &mut Vec>, + citation_context: C, +) where + C: Into, +{ + let citation_context: MarkdownCitationContext = citation_context.into(); + append_markdown_with_opener_and_cwd( + markdown_source, + width, + lines, + citation_context.file_opener, + &citation_context.cwd, + ); +} + +pub(crate) fn append_markdown_with_opener_and_cwd( + markdown_source: &str, + width: Option, lines: &mut Vec>, file_opener: UriBasedFileOpener, cwd: &Path, @@ -20,6 +50,7 @@ fn append_markdown_with_opener_and_cwd( // Render via pulldown-cmark and rewrite citations during traversal (outside code blocks). let rendered = crate::markdown_render::render_markdown_text_with_citations( markdown_source, + width, file_opener.get_scheme(), cwd, ); @@ -36,7 +67,7 @@ mod tests { let src = "Before 【F:/x.rs†L1】\n```\nInside 【F:/x.rs†L2】\n```\nAfter 【F:/x.rs†L3】\n"; let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd); + append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd); let rendered: Vec = out .iter() .map(|l| { @@ -69,7 +100,7 @@ mod tests { let src = "Before\n\n code 1\n\nAfter\n"; let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd); + append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); let lines: Vec = out .iter() .map(|l| { @@ -87,7 +118,7 @@ mod tests { let src = "Start 【F:/x.rs†L1】\n\n Inside 【F:/x.rs†L2】\n\nEnd 【F:/x.rs†L3】\n"; let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::VsCode, cwd); + append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::VsCode, cwd); let rendered: Vec = out .iter() .map(|l| { @@ -117,7 +148,7 @@ mod tests { let src = "Hi! How can I help with codex-rs today? Want me to explore the repo, run tests, or work on a specific change?\n"; let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd); + append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); assert_eq!( out.len(), 1, @@ -143,6 +174,7 @@ mod tests { let mut out = Vec::new(); append_markdown_with_opener_and_cwd( "1. Tight item\n", + None, &mut out, UriBasedFileOpener::None, cwd, @@ -166,7 +198,7 @@ mod tests { let src = "Loose vs. tight list items:\n1. Tight item\n"; let cwd = Path::new("/"); let mut out = Vec::new(); - append_markdown_with_opener_and_cwd(src, &mut out, UriBasedFileOpener::None, cwd); + append_markdown_with_opener_and_cwd(src, None, &mut out, UriBasedFileOpener::None, cwd); let lines: Vec = out .iter() diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 4839346380..d945b39cb3 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -1,4 +1,7 @@ use crate::citation_regex::CITATION_REGEX; +use crate::render::line_utils::line_to_static; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; use pulldown_cmark::CodeBlockKind; use pulldown_cmark::CowStr; use pulldown_cmark::Event; @@ -32,25 +35,30 @@ impl IndentContext { } } -#[allow(dead_code)] pub fn render_markdown_text(input: &str) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); - let mut w = Writer::new(parser, None, None); + let mut w = Writer::new(parser, None, None, None); w.run(); w.text } pub(crate) fn render_markdown_text_with_citations( input: &str, + width: Option, scheme: Option<&str>, cwd: &Path, ) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); - let mut w = Writer::new(parser, scheme.map(str::to_string), Some(cwd.to_path_buf())); + let mut w = Writer::new( + parser, + scheme.map(str::to_string), + Some(cwd.to_path_buf()), + width, + ); w.run(); w.text } @@ -71,13 +79,24 @@ where scheme: Option, cwd: Option, in_code_block: bool, + wrap_width: Option, + current_line_content: Option>, + current_initial_indent: Vec>, + current_subsequent_indent: Vec>, + current_line_style: Style, + current_line_in_code_block: bool, } impl<'a, I> Writer<'a, I> where I: Iterator>, { - fn new(iter: I, scheme: Option, cwd: Option) -> Self { + fn new( + iter: I, + scheme: Option, + cwd: Option, + wrap_width: Option, + ) -> Self { Self { iter, text: Text::default(), @@ -91,6 +110,12 @@ where scheme, cwd, in_code_block: false, + wrap_width, + current_line_content: None, + current_initial_indent: Vec::new(), + current_subsequent_indent: Vec::new(), + current_line_style: Style::default(), + current_line_in_code_block: false, } } @@ -98,6 +123,7 @@ where while let Some(ev) = self.iter.next() { self.handle_event(ev); } + self.flush_current_line(); } fn handle_event(&mut self, event: Event<'a>) { @@ -109,6 +135,7 @@ where Event::SoftBreak => self.soft_break(), Event::HardBreak => self.hard_break(), Event::Rule => { + self.flush_current_line(); if !self.text.lines.is_empty() { self.push_blank_line(); } @@ -237,16 +264,21 @@ where self.push_line(Line::default()); } self.pending_marker_line = false; - if self.in_code_block - && !self.needs_newline - && self - .text - .lines - .last() + if self.in_code_block && !self.needs_newline { + let has_content = self + .current_line_content + .as_ref() .map(|line| !line.spans.is_empty()) - .unwrap_or(false) - { - self.push_line(Line::default()); + .unwrap_or_else(|| { + self.text + .lines + .last() + .map(|line| !line.spans.is_empty()) + .unwrap_or(false) + }); + if has_content { + self.push_line(Line::default()); + } } for (i, line) in text.lines().enumerate() { if self.needs_newline { @@ -351,6 +383,7 @@ where } fn start_codeblock(&mut self, _lang: Option, indent: Option>) { + self.flush_current_line(); if !self.text.lines.is_empty() { self.push_blank_line(); } @@ -360,16 +393,10 @@ where None, false, )); - // let opener = match lang { - // Some(l) if !l.is_empty() => format!("```{l}"), - // _ => "```".to_string(), - // }; - // self.push_line(opener.into()); self.needs_newline = true; } fn end_codeblock(&mut self) { - // self.push_line("```".into()); self.needs_newline = true; self.in_code_block = false; self.indent_stack.pop(); @@ -397,11 +424,34 @@ where } } + fn flush_current_line(&mut self) { + if let Some(line) = self.current_line_content.take() { + let style = self.current_line_style; + // NB we don't wrap code in code blocks, in order to preserve whitespace for copy/paste. + if !self.current_line_in_code_block + && let Some(width) = self.wrap_width + { + let opts = RtOptions::new(width) + .initial_indent(self.current_initial_indent.clone().into()) + .subsequent_indent(self.current_subsequent_indent.clone().into()); + for wrapped in word_wrap_line(&line, opts) { + let owned = line_to_static(&wrapped).style(style); + self.text.lines.push(owned); + } + } else { + let mut spans = self.current_initial_indent.clone(); + let mut line = line; + spans.append(&mut line.spans); + self.text.lines.push(Line::from_iter(spans).style(style)); + } + self.current_initial_indent.clear(); + self.current_subsequent_indent.clear(); + self.current_line_in_code_block = false; + } + } + fn push_line(&mut self, line: Line<'static>) { - let mut line = line; - let was_pending = self.pending_marker_line; - let mut spans = self.current_prefix_spans(); - spans.append(&mut line.spans); + self.flush_current_line(); let blockquote_active = self .indent_stack .iter() @@ -411,31 +461,38 @@ where } else { line.style }; - self.text.lines.push(Line::from_iter(spans).style(style)); - if was_pending { - self.pending_marker_line = false; - } + let was_pending = self.pending_marker_line; + + self.current_initial_indent = self.prefix_spans(was_pending); + self.current_subsequent_indent = self.prefix_spans(false); + self.current_line_style = style; + self.current_line_content = Some(line); + self.current_line_in_code_block = self.in_code_block; + + self.pending_marker_line = false; } fn push_span(&mut self, span: Span<'static>) { - if let Some(last) = self.text.lines.last_mut() { - last.push_span(span); + if let Some(line) = self.current_line_content.as_mut() { + line.push_span(span); } else { self.push_line(Line::from(vec![span])); } } fn push_blank_line(&mut self) { + self.flush_current_line(); if self.indent_stack.iter().all(|ctx| ctx.is_list) { self.text.lines.push(Line::default()); } else { self.push_line(Line::default()); + self.flush_current_line(); } } - fn current_prefix_spans(&self) -> Vec> { + fn prefix_spans(&self, pending_marker_line: bool) -> Vec> { let mut prefix: Vec> = Vec::new(); - let last_marker_index = if self.pending_marker_line { + let last_marker_index = if pending_marker_line { self.indent_stack .iter() .enumerate() @@ -447,7 +504,7 @@ where let last_list_index = self.indent_stack.iter().rposition(|ctx| ctx.is_list); for (i, ctx) in self.indent_stack.iter().enumerate() { - if self.pending_marker_line { + if pending_marker_line { if Some(i) == last_marker_index && let Some(marker) = &ctx.marker { @@ -514,6 +571,19 @@ mod markdown_render_tests { mod tests { use super::*; use pretty_assertions::assert_eq; + use ratatui::text::Text; + + fn lines_to_strings(text: &Text<'_>) -> Vec { + text.lines + .iter() + .map(|l| { + l.spans + .iter() + .map(|s| s.content.clone()) + .collect::() + }) + .collect() + } #[test] fn citation_is_rewritten_with_absolute_path() { @@ -546,17 +616,136 @@ mod tests { let unchanged = rewrite_file_citations_with_scheme(markdown, Some("vscode"), cwd); // The helper itself always rewrites – this test validates behaviour of // append_markdown when `file_opener` is None. - let rendered = render_markdown_text_with_citations(markdown, None, cwd); + let rendered = render_markdown_text_with_citations(markdown, None, None, cwd); // Convert lines back to string for comparison. - let rendered: String = rendered - .lines - .iter() - .flat_map(|l| l.spans.iter()) - .map(|s| s.content.clone()) - .collect::>() - .join(""); + let rendered: String = lines_to_strings(&rendered).join(""); assert_eq!(markdown, rendered); // Ensure helper rewrites. assert_ne!(markdown, unchanged); } + + #[test] + fn wraps_plain_text_when_width_provided() { + let markdown = "This is a simple sentence that should wrap."; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(16), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "This is a simple".to_string(), + "sentence that".to_string(), + "should wrap.".to_string(), + ] + ); + } + + #[test] + fn wraps_list_items_preserving_indent() { + let markdown = "- first second third fourth"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(14), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec!["- first second".to_string(), " third fourth".to_string(),] + ); + } + + #[test] + fn wraps_nested_lists() { + let markdown = + "- outer item with several words to wrap\n - inner item that also needs wrapping"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(20), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "- outer item with".to_string(), + " several words".to_string(), + " to wrap".to_string(), + " - inner item".to_string(), + " that also".to_string(), + " needs wrapping".to_string(), + ] + ); + } + + #[test] + fn wraps_ordered_lists() { + let markdown = "1. ordered item contains many words for wrapping"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(18), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "1. ordered item".to_string(), + " contains many".to_string(), + " words for".to_string(), + " wrapping".to_string(), + ] + ); + } + + #[test] + fn wraps_blockquotes() { + let markdown = "> block quote with content that should wrap nicely"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(22), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "> block quote with".to_string(), + "> content that should".to_string(), + "> wrap nicely".to_string(), + ] + ); + } + + #[test] + fn wraps_blockquotes_inside_lists() { + let markdown = "- list item\n > block quote inside list that wraps"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "- list item".to_string(), + " > block quote inside".to_string(), + " > list that wraps".to_string(), + ] + ); + } + + #[test] + fn wraps_list_items_containing_blockquotes() { + let markdown = "1. item with quote\n > quoted text that should wrap"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(24), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec![ + "1. item with quote".to_string(), + " > quoted text that".to_string(), + " > should wrap".to_string(), + ] + ); + } + + #[test] + fn does_not_wrap_code_blocks() { + let markdown = "````\nfn main() { println!(\"hi from a long line\"); }\n````"; + let cwd = Path::new("/"); + let rendered = render_markdown_text_with_citations(markdown, Some(10), None, cwd); + let lines = lines_to_strings(&rendered); + assert_eq!( + lines, + vec!["fn main() { println!(\"hi from a long line\"); }".to_string(),] + ); + } } diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 7c439337b7..efe5811824 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -8,13 +8,15 @@ use crate::markdown; pub(crate) struct MarkdownStreamCollector { buffer: String, committed_line_count: usize, + width: Option, } impl MarkdownStreamCollector { - pub fn new() -> Self { + pub fn new(width: Option) -> Self { Self { buffer: String::new(), committed_line_count: 0, + width, } } @@ -40,7 +42,7 @@ impl MarkdownStreamCollector { return Vec::new(); }; let mut rendered: Vec> = Vec::new(); - markdown::append_markdown(&source, &mut rendered, config); + markdown::append_markdown(&source, self.width, &mut rendered, config); let mut complete_line_count = rendered.len(); if complete_line_count > 0 && crate::render::line_utils::is_blank_line_spaces_only( @@ -81,7 +83,7 @@ impl MarkdownStreamCollector { tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---"); let mut rendered: Vec> = Vec::new(); - markdown::append_markdown(&source, &mut rendered, config); + markdown::append_markdown(&source, self.width, &mut rendered, config); let out = if self.committed_line_count >= rendered.len() { Vec::new() @@ -101,7 +103,7 @@ pub(crate) fn simulate_stream_markdown_for_tests( finalize: bool, config: &Config, ) -> Vec> { - let mut collector = MarkdownStreamCollector::new(); + let mut collector = MarkdownStreamCollector::new(None); let mut out = Vec::new(); for d in deltas { collector.push_delta(d); @@ -136,7 +138,7 @@ mod tests { #[test] fn no_commit_until_newline() { let cfg = test_config(); - let mut c = super::MarkdownStreamCollector::new(); + let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Hello, world"); let out = c.commit_complete_lines(&cfg); assert!(out.is_empty(), "should not commit without newline"); @@ -148,7 +150,7 @@ mod tests { #[test] fn finalize_commits_partial_line() { let cfg = test_config(); - let mut c = super::MarkdownStreamCollector::new(); + let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Line without newline"); let out = c.finalize_and_drain(&cfg); assert_eq!(out.len(), 1); @@ -277,7 +279,7 @@ mod tests { // Stream a paragraph line, then a heading on the next line. // Expect two distinct rendered lines: "Hello." and "Heading". - let mut c = super::MarkdownStreamCollector::new(); + let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Hello.\n"); let out1 = c.commit_complete_lines(&cfg); let s1: Vec = out1 @@ -335,7 +337,7 @@ mod tests { // Paragraph without trailing newline, then a chunk that starts with the newline // and the heading text, then a final newline. The collector should first commit // only the paragraph line, and later commit the heading as its own line. - let mut c = super::MarkdownStreamCollector::new(); + let mut c = super::MarkdownStreamCollector::new(None); c.push_delta("Sounds good!"); // No commit yet assert!(c.commit_complete_lines(&cfg).is_empty()); @@ -380,7 +382,7 @@ mod tests { // Sanity check raw markdown rendering for a simple line does not produce spurious extras. let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown("Hello.\n", &mut rendered, &cfg); + crate::markdown::append_markdown("Hello.\n", None, &mut rendered, &cfg); let rendered_strings: Vec = rendered .iter() .map(|l| { @@ -442,7 +444,7 @@ mod tests { let streamed_str = lines_to_plain_strings(&streamed); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(input, &mut rendered_all, &cfg); + crate::markdown::append_markdown(input, None, &mut rendered_all, &cfg); let rendered_all_str = lines_to_plain_strings(&rendered_all); assert_eq!( @@ -552,7 +554,7 @@ mod tests { let full: String = deltas.iter().copied().collect(); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, &mut rendered_all, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg); let rendered_all_strs = lines_to_plain_strings(&rendered_all); assert_eq!( @@ -641,7 +643,7 @@ mod tests { // Compute a full render for diagnostics only. let full: String = deltas.iter().copied().collect(); let mut rendered_all: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, &mut rendered_all, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered_all, &cfg); // Also assert exact expected plain strings for clarity. let expected = vec![ @@ -669,7 +671,7 @@ mod tests { let streamed_strs = lines_to_plain_strings(&streamed); let full: String = deltas.iter().copied().collect(); let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown(&full, &mut rendered, &cfg); + crate::markdown::append_markdown(&full, None, &mut rendered, &cfg); let rendered_strs = lines_to_plain_strings(&rendered); assert_eq!(streamed_strs, rendered_strs, "full:\n---\n{full}\n---"); } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2ea59a312a..cf481e6712 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -27,7 +27,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; -use codex_protocol::mcp_protocol::AuthMode; +use codex_app_server_protocol::AuthMode; use std::sync::RwLock; use crate::LoginStatus; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 5806424b32..6408f237fa 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -11,7 +11,7 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_protocol::mcp_protocol::AuthMode; +use codex_app_server_protocol::AuthMode; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index d983ba27c0..35f096e742 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -7,7 +7,7 @@ use codex_common::create_config_summary_entries; use codex_core::config::Config; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; -use codex_protocol::mcp_protocol::ConversationId; +use codex_protocol::ConversationId; use ratatui::prelude::*; use ratatui::style::Stylize; use std::collections::BTreeSet; diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index ca22bed590..a7c45d1fc4 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -15,10 +15,10 @@ pub(crate) struct StreamController { } impl StreamController { - pub(crate) fn new(config: Config) -> Self { + pub(crate) fn new(config: Config, width: Option) -> Self { Self { config, - state: StreamState::new(), + state: StreamState::new(width), finishing_after_drain: false, header_emitted: false, } @@ -118,7 +118,7 @@ mod tests { #[test] fn controller_loose_vs_tight_with_commit_ticks_matches_full() { let cfg = test_config(); - let mut ctrl = StreamController::new(cfg.clone()); + let mut ctrl = StreamController::new(cfg.clone(), None); let mut lines = Vec::new(); // Exact deltas from the session log (section: Loose vs. tight list items) @@ -223,7 +223,7 @@ mod tests { // Full render of the same source let source: String = deltas.iter().copied().collect(); let mut rendered: Vec> = Vec::new(); - crate::markdown::append_markdown(&source, &mut rendered, &cfg); + crate::markdown::append_markdown(&source, None, &mut rendered, &cfg); let rendered_strs = lines_to_plain_strings(&rendered); assert_eq!(streamed, rendered_strs); diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index e048b3991d..fa00702834 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -12,9 +12,9 @@ pub(crate) struct StreamState { } impl StreamState { - pub(crate) fn new() -> Self { + pub(crate) fn new(width: Option) -> Self { Self { - collector: MarkdownStreamCollector::new(), + collector: MarkdownStreamCollector::new(width), queued_lines: VecDeque::new(), has_seen_delta: false, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d65067a3f..0f4465a220 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ importers: ts-jest: specifier: ^29.3.4 version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + ts-jest-mock-import-meta: + specifier: ^1.3.1 + version: 1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.18)(typescript@5.9.2) @@ -1979,6 +1982,11 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + ts-jest-mock-import-meta@1.3.1: + resolution: {integrity: sha512-KGrp9Nh/SdyrQs5hZvtkp0CFPOgAh3DL57NZgFRbtlvMyEo7XuXLbeyylmxFZGGu30pL338h9KxwSxrNDndygw==} + peerDependencies: + ts-jest: '>=20.0.0' + ts-jest@29.4.4: resolution: {integrity: sha512-ccVcRABct5ZELCT5U0+DZwkXMCcOCLi2doHRrKy1nK/s7J7bch6TzJMsrY09WxgUUIP/ITfmcDS8D2yl63rnXw==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -4291,6 +4299,10 @@ snapshots: ts-interface-checker@0.1.13: {} + ts-jest-mock-import-meta@1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)): + dependencies: + ts-jest: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2): dependencies: bs-logger: 0.2.6 diff --git a/sdk/typescript/jest.config.cjs b/sdk/typescript/jest.config.cjs index 55d25cd842..05d51f832c 100644 --- a/sdk/typescript/jest.config.cjs +++ b/sdk/typescript/jest.config.cjs @@ -3,14 +3,29 @@ module.exports = { preset: "ts-jest/presets/default-esm", testEnvironment: "node", extensionsToTreatAsEsm: [".ts"], - globals: { - "ts-jest": { - useESM: true, - tsconfig: "tsconfig.json", - }, - }, moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", }, testMatch: ["**/tests/**/*.test.ts"], + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: "tsconfig.json", + diagnostics: { + ignoreCodes: [1343], + }, + astTransformers: { + before: [ + { + path: "ts-jest-mock-import-meta", + // Workaround for meta.url not working in jest + options: { metaObjectReplacement: { url: "file://" + __dirname + "/dist/index.js" } }, + }, + ], + }, + }, + ], + }, }; diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index b68f939c12..dac7a89d6e 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -42,15 +42,16 @@ "devDependencies": { "@types/jest": "^29.5.14", "@types/node": "^20.19.18", - "typescript-eslint": "^8.45.0", "eslint": "^9.36.0", "eslint-config-prettier": "^9.1.2", + "eslint-plugin-jest": "^29.0.1", "jest": "^29.7.0", "prettier": "^3.6.2", "ts-jest": "^29.3.4", "ts-node": "^10.9.2", "tsup": "^8.5.0", "typescript": "^5.9.2", - "eslint-plugin-jest": "^29.0.1" + "typescript-eslint": "^8.45.0", + "ts-jest-mock-import-meta": "^1.3.1" } } diff --git a/sdk/typescript/src/codex.ts b/sdk/typescript/src/codex.ts index 36275bc7d0..bb4b713550 100644 --- a/sdk/typescript/src/codex.ts +++ b/sdk/typescript/src/codex.ts @@ -7,11 +7,7 @@ export class Codex { private options: CodexOptions; constructor(options: CodexOptions) { - if (!options.executablePath) { - throw new Error("executablePath is required"); - } - - this.exec = new CodexExec(options.executablePath); + this.exec = new CodexExec(options.codexPathOverride); this.options = options; } diff --git a/sdk/typescript/src/codexOptions.ts b/sdk/typescript/src/codexOptions.ts index 16113bc2de..2d22bcf227 100644 --- a/sdk/typescript/src/codexOptions.ts +++ b/sdk/typescript/src/codexOptions.ts @@ -1,7 +1,5 @@ export type CodexOptions = { - // TODO: remove - executablePath: string; - // TODO: remove + codexPathOverride?: string; baseUrl?: string; apiKey?: string; }; diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index 0369f8670f..6ae5b441a7 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -1,22 +1,38 @@ import { spawn } from "child_process"; + import readline from "node:readline"; +import { SandboxMode } from "./turnOptions"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + export type CodexExecArgs = { input: string; baseUrl?: string; apiKey?: string; threadId?: string | null; + model?: string; + sandboxMode?: SandboxMode; }; export class CodexExec { private executablePath: string; - constructor(executablePath: string) { - this.executablePath = executablePath; + constructor(executablePath: string | null = null) { + this.executablePath = executablePath || findCodexPath(); } async *run(args: CodexExecArgs): AsyncGenerator { const commandArgs: string[] = ["exec", "--experimental-json"]; + + if (args.model) { + commandArgs.push("--model", args.model); + } + + if (args.sandboxMode) { + commandArgs.push("--sandbox", args.sandboxMode); + } + if (args.threadId) { commandArgs.push("resume", args.threadId, args.input); } else { @@ -79,3 +95,64 @@ export class CodexExec { } } } + +const scriptFileName = fileURLToPath(import.meta.url); +const scriptDirName = path.dirname(scriptFileName); + +function findCodexPath() { + const { platform, arch } = process; + + let targetTriple = null; + switch (platform) { + case "linux": + case "android": + switch (arch) { + case "x64": + targetTriple = "x86_64-unknown-linux-musl"; + break; + case "arm64": + targetTriple = "aarch64-unknown-linux-musl"; + break; + default: + break; + } + break; + case "darwin": + switch (arch) { + case "x64": + targetTriple = "x86_64-apple-darwin"; + break; + case "arm64": + targetTriple = "aarch64-apple-darwin"; + break; + default: + break; + } + break; + case "win32": + switch (arch) { + case "x64": + targetTriple = "x86_64-pc-windows-msvc"; + break; + case "arm64": + targetTriple = "aarch64-pc-windows-msvc"; + break; + default: + break; + } + break; + default: + break; + } + + if (!targetTriple) { + throw new Error(`Unsupported platform: ${platform} (${arch})`); + } + + const vendorRoot = path.join(scriptDirName, "..", "vendor"); + const archRoot = path.join(vendorRoot, targetTriple); + const codexBinaryName = process.platform === "win32" ? "codex.exe" : "codex"; + const binaryPath = path.join(archRoot, "codex", codexBinaryName); + + return binaryPath; +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 02bbfa1b33..5fa6d5bc4e 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -22,8 +22,10 @@ export type { ErrorItem, } from "./items"; -export type { Thread, RunResult, RunStreamedResult, Input } from "./thread"; +export { Thread, RunResult, RunStreamedResult, Input } from "./thread"; -export type { Codex } from "./codex"; +export { Codex } from "./codex"; export type { CodexOptions } from "./codexOptions"; + +export type { TurnOptions, ApprovalMode, SandboxMode } from "./turnOptions"; diff --git a/sdk/typescript/src/thread.ts b/sdk/typescript/src/thread.ts index cd19a94288..3e380e0aea 100644 --- a/sdk/typescript/src/thread.ts +++ b/sdk/typescript/src/thread.ts @@ -2,6 +2,7 @@ import { CodexOptions } from "./codexOptions"; import { ThreadEvent } from "./events"; import { CodexExec } from "./exec"; import { ThreadItem } from "./items"; +import { TurnOptions } from "./turnOptions"; export type RunResult = { items: ThreadItem[]; @@ -25,16 +26,21 @@ export class Thread { this.id = id; } - async runStreamed(input: string): Promise { - return { events: this.runStreamedInternal(input) }; + async runStreamed(input: string, options?: TurnOptions): Promise { + return { events: this.runStreamedInternal(input, options) }; } - private async *runStreamedInternal(input: string): AsyncGenerator { + private async *runStreamedInternal( + input: string, + options?: TurnOptions, + ): AsyncGenerator { const generator = this.exec.run({ input, baseUrl: this.options.baseUrl, apiKey: this.options.apiKey, threadId: this.id, + model: options?.model, + sandboxMode: options?.sandboxMode, }); for await (const item of generator) { const parsed = JSON.parse(item) as ThreadEvent; @@ -45,8 +51,8 @@ export class Thread { } } - async run(input: string): Promise { - const generator = this.runStreamedInternal(input); + async run(input: string, options?: TurnOptions): Promise { + const generator = this.runStreamedInternal(input, options); const items: ThreadItem[] = []; let finalResponse: string = ""; for await (const event of generator) { diff --git a/sdk/typescript/src/turnOptions.ts b/sdk/typescript/src/turnOptions.ts new file mode 100644 index 0000000000..c414334d19 --- /dev/null +++ b/sdk/typescript/src/turnOptions.ts @@ -0,0 +1,8 @@ +export type ApprovalMode = "never" | "on-request" | "on-failure" | "untrusted"; + +export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; + +export type TurnOptions = { + model?: string; + sandboxMode?: SandboxMode; +}; diff --git a/sdk/typescript/tests/codexExecSpy.ts b/sdk/typescript/tests/codexExecSpy.ts new file mode 100644 index 0000000000..9028b6e6e5 --- /dev/null +++ b/sdk/typescript/tests/codexExecSpy.ts @@ -0,0 +1,29 @@ +import * as child_process from "child_process"; + +jest.mock("child_process", () => { + const actual = jest.requireActual("child_process"); + return { ...actual, spawn: jest.fn(actual.spawn) }; +}); + +const actualChildProcess = jest.requireActual("child_process"); +const spawnMock = child_process.spawn as jest.MockedFunction; + +export function codexExecSpy(): { args: string[][]; restore: () => void } { + const previousImplementation = + spawnMock.getMockImplementation() ?? actualChildProcess.spawn; + const args: string[][] = []; + + spawnMock.mockImplementation(((...spawnArgs: Parameters) => { + const commandArgs = spawnArgs[1]; + args.push(Array.isArray(commandArgs) ? [...commandArgs] : []); + return previousImplementation(...spawnArgs); + }) as typeof actualChildProcess.spawn); + + return { + args, + restore: () => { + spawnMock.mockClear(); + spawnMock.mockImplementation(previousImplementation); + }, + }; +} diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index 5c7815b538..7fcdc9c715 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -25,6 +25,7 @@ export type ResponsesProxy = { }; export type ResponsesApiRequest = { + model?: string; input: Array<{ role: string; content?: Array<{ type: string; text: string }>; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index 7f6c57c2d3..8c7dff7725 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -1,5 +1,6 @@ import path from "path"; +import { codexExecSpy } from "./codexExecSpy"; import { describe, expect, it } from "@jest/globals"; import { Codex } from "../src/codex"; @@ -22,7 +23,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const result = await thread.run("Hello, world!"); @@ -59,7 +60,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); await thread.run("first input"); @@ -84,6 +85,52 @@ describe("Codex", () => { } }); + it("continues the thread when run is called twice with options", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("First response", "item_1"), + responseCompleted("response_1"), + ), + sse( + responseStarted("response_2"), + assistantMessage("Second response", "item_2"), + responseCompleted("response_2"), + ), + ], + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("first input"); + await thread.run("second input", { + model: "gpt-test-1", + }); + + // Check second request continues the same thread + expect(requests.length).toBeGreaterThanOrEqual(2); + const secondRequest = requests[1]; + expect(secondRequest).toBeDefined(); + const payload = secondRequest!.json; + + expect(payload.model).toBe("gpt-test-1"); + const assistantEntry = payload.input.find( + (entry: { role: string }) => entry.role === "assistant", + ); + expect(assistantEntry).toBeDefined(); + const assistantText = assistantEntry?.content?.find( + (item: { type: string; text: string }) => item.type === "output_text", + )?.text; + expect(assistantText).toBe("First response"); + } finally { + await close(); + } + }); + it("resumes thread by id", async () => { const { url, close, requests } = await startResponsesTestProxy({ statusCode: 200, @@ -102,7 +149,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const originalThread = client.startThread(); await originalThread.run("first input"); @@ -130,4 +177,56 @@ describe("Codex", () => { await close(); } }); + + it("passes turn options to exec", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [ + sse( + responseStarted("response_1"), + assistantMessage("Turn options applied", "item_1"), + responseCompleted("response_1"), + ), + ], + }); + + const { args: spawnArgs, restore } = codexExecSpy(); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("apply options", { + model: "gpt-test-1", + sandboxMode: "workspace-write", + }); + + const payload = requests[0]; + expect(payload).toBeDefined(); + const json = payload!.json as { model?: string } | undefined; + + expect(json?.model).toBe("gpt-test-1"); + expect(spawnArgs.length).toBeGreaterThan(0); + const commandArgs = spawnArgs[0]; + + expectPair(commandArgs, ["--sandbox", "workspace-write"]); + expectPair(commandArgs, ["--model", "gpt-test-1"]); + + } finally { + restore(); + await close(); + } + }); }); + + +function expectPair(args: string[] | undefined, pair: [string, string]) { + if (!args) { + throw new Error("Args is undefined"); + } + const index = args.indexOf(pair[0]); + if (index === -1) { + throw new Error(`Pair ${pair[0]} not found in args`); + } + expect(args[index + 1]).toBe(pair[1]); +} diff --git a/sdk/typescript/tests/runStreamed.test.ts b/sdk/typescript/tests/runStreamed.test.ts index 85b5cf38df..9f60cef02e 100644 --- a/sdk/typescript/tests/runStreamed.test.ts +++ b/sdk/typescript/tests/runStreamed.test.ts @@ -23,7 +23,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const result = await thread.runStreamed("Hello, world!"); @@ -82,7 +82,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const thread = client.startThread(); const first = await thread.runStreamed("first input"); @@ -128,7 +128,7 @@ describe("Codex", () => { }); try { - const client = new Codex({ executablePath: codexExecPath, baseUrl: url, apiKey: "test" }); + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); const originalThread = client.startThread(); const first = await originalThread.runStreamed("first input"); diff --git a/sdk/typescript/tsconfig.json b/sdk/typescript/tsconfig.json index 7dd88b00f4..cdbc4f30c2 100644 --- a/sdk/typescript/tsconfig.json +++ b/sdk/typescript/tsconfig.json @@ -16,7 +16,8 @@ "declaration": true, "declarationMap": true, "noImplicitAny": true, - "outDir": "dist" + "outDir": "dist", + "stripInternal": true }, "include": ["src", "tests", "tsup.config.ts"], "exclude": ["dist", "node_modules"]