diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 004b7d1709..51b5cd8a85 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -43,6 +43,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadCapabilitiesReadResponse; use codex_arg0::Arg0DispatchPaths; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; @@ -93,6 +94,9 @@ pub mod legacy_core { } const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); +const JSONRPC_INVALID_REQUEST: i64 = -32600; +const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; +const THREAD_CAPABILITIES_READ_METHOD: &str = "thread/capabilities/read"; /// Runs the embedded app-server personality migration. /// @@ -850,6 +854,40 @@ impl AppServerRequestHandle { } impl AppServerClient { + /// Returns whether the connected server supports disabling multi-agent + /// collaboration when forking a thread. + /// + /// In-process clients always match this crate version. Remote clients + /// probe the additive capability RPC and treat older servers as + /// unsupported before any mutating fork request is sent. + pub async fn supports_thread_fork_disable_multi_agent_tools( + &self, + request_id: RequestId, + ) -> Result { + let Self::Remote(client) = self else { + return Ok(true); + }; + match client + .request_typed::( + ClientRequest::ThreadCapabilitiesRead { + request_id, + params: None, + }, + ) + .await + { + Ok(response) => Ok(response.fork_disable_multi_agent_tools), + Err(TypedRequestError::Server { source, .. }) + if source.code == JSONRPC_METHOD_NOT_FOUND + || (source.code == JSONRPC_INVALID_REQUEST + && source.message.contains(THREAD_CAPABILITIES_READ_METHOD)) => + { + Ok(false) + } + Err(err) => Err(err), + } + } + pub async fn request(&self, request: ClientRequest) -> IoResult { match self { Self::InProcess(client) => client.request(request).await, @@ -938,6 +976,7 @@ mod tests { use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::GetAccountResponse; + use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; @@ -1465,6 +1504,45 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_disabled_fork_capability_probe_fails_closed_on_old_server() { + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected thread capability request"); + }; + assert_eq!(request.method, THREAD_CAPABILITIES_READ_METHOD); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Error(JSONRPCError { + id: request.id, + error: JSONRPCErrorError { + code: JSONRPC_METHOD_NOT_FOUND, + message: "method not found".to_string(), + data: None, + }, + }), + ) + .await; + }) + .await; + let client = AppServerClient::Remote( + RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"), + ); + + assert!( + !client + .supports_thread_fork_disable_multi_agent_tools(RequestId::Integer(1)) + .await + .expect("old-server capability probe should fail closed") + ); + + client.shutdown().await.expect("shutdown should complete"); + } + #[tokio::test] async fn remote_unix_socket_typed_request_roundtrip_works() { let socket_dir = TempDir::new().expect("socket dir"); diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 1e6420d804..91e8cb51ca 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -4505,6 +4505,29 @@ "title": "Thread/forkRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/capabilities/read" + ], + "title": "Thread/capabilities/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Thread/capabilities/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index c35f8e16dc..e25ab7a121 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -300,6 +300,29 @@ "title": "Thread/forkRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/capabilities/read" + ], + "title": "Thread/capabilities/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Thread/capabilities/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -16069,6 +16092,20 @@ "title": "ThreadArchivedNotification", "type": "object" }, + "ThreadCapabilitiesReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "forkDisableMultiAgentTools": { + "description": "Whether `thread/fork` supports disabling multi-agent collaboration.", + "type": "boolean" + } + }, + "required": [ + "forkDisableMultiAgentTools" + ], + "title": "ThreadCapabilitiesReadResponse", + "type": "object" + }, "ThreadClosedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6831ce3357..584d713098 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1242,6 +1242,29 @@ "title": "Thread/forkRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/capabilities/read" + ], + "title": "Thread/capabilities/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Thread/capabilities/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -13886,6 +13909,20 @@ "title": "ThreadArchivedNotification", "type": "object" }, + "ThreadCapabilitiesReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "forkDisableMultiAgentTools": { + "description": "Whether `thread/fork` supports disabling multi-agent collaboration.", + "type": "boolean" + } + }, + "required": [ + "forkDisableMultiAgentTools" + ], + "title": "ThreadCapabilitiesReadResponse", + "type": "object" + }, "ThreadClosedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadCapabilitiesReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadCapabilitiesReadResponse.json new file mode 100644 index 0000000000..7f9731fe7c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadCapabilitiesReadResponse.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "forkDisableMultiAgentTools": { + "description": "Whether `thread/fork` supports disabling multi-agent collaboration.", + "type": "boolean" + } + }, + "required": [ + "forkDisableMultiAgentTools" + ], + "title": "ThreadCapabilitiesReadResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index b6a13fe307..52ec76fffb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -87,4 +87,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/capabilities/read", id: RequestId, params: undefined, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadCapabilitiesReadResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadCapabilitiesReadResponse.ts new file mode 100644 index 0000000000..bf71e224f4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadCapabilitiesReadResponse.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadCapabilitiesReadResponse = { +/** + * Whether `thread/fork` supports disabling multi-agent collaboration. + */ +forkDisableMultiAgentTools: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index b4cd88aadf..c7a477bff1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -371,6 +371,7 @@ export type { ThreadApproveGuardianDeniedActionResponse } from "./ThreadApproveG export type { ThreadArchiveParams } from "./ThreadArchiveParams"; export type { ThreadArchiveResponse } from "./ThreadArchiveResponse"; export type { ThreadArchivedNotification } from "./ThreadArchivedNotification"; +export type { ThreadCapabilitiesReadResponse } from "./ThreadCapabilitiesReadResponse"; export type { ThreadClosedNotification } from "./ThreadClosedNotification"; export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9311fe9058..e7e3d325b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -480,6 +480,11 @@ client_request_definitions! { serialization: thread_or_path(params.thread_id, params.path), response: v2::ThreadForkResponse, }, + ThreadCapabilitiesRead => "thread/capabilities/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::ThreadCapabilitiesReadResponse, + }, ThreadArchive => "thread/archive" { params: v2::ThreadArchiveParams, serialization: thread_id(params.thread_id), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index cdc8087553..2d75fe3cf2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -589,6 +589,14 @@ pub struct ThreadForkResponse { pub reasoning_effort: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCapabilitiesReadResponse { + /// Whether `thread/fork` supports disabling multi-agent collaboration. + pub fork_disable_multi_agent_tools: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2e074dc5ba..5784edd229 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -132,7 +132,8 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment; other plugin capabilities are not activated yet. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Set `disableMultiAgentTools: true` to prevent the fork from inheriting or enabling multi-agent collaboration tools; the response sets `multiAgentToolsDisabled: true` when the forked runtime applied that restriction, and clients that require it should reject a missing or false acknowledgment. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. +- `thread/capabilities/read` — read optional thread behavior supported by this app-server. Clients must require `forkDisableMultiAgentTools: true` before sending a `thread/fork` request with `disableMultiAgentTools: true`; older servers reject this capability request without creating a thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Set `disableMultiAgentTools: true` to prevent the fork from inheriting or enabling multi-agent collaboration tools; supporting servers return `multiAgentToolsDisabled: true` to acknowledge that the forked runtime applied the restriction. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. - `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6ba59e733f..d18e3aaa0b 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1043,6 +1043,9 @@ impl MessageProcessor { ) .await } + ClientRequest::ThreadCapabilitiesRead { params: _, .. } => Ok(Some( + self.thread_processor.thread_capabilities_read().into(), + )), ClientRequest::ThreadUnsubscribe { params, .. } => { self.thread_processor .thread_unsubscribe(&request_id, params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 94727c3538..d108eb4742 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -178,6 +178,7 @@ use codex_app_server_protocol::ThreadBackgroundTerminalsListParams; use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse; use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateParams; use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateResponse; +use codex_app_server_protocol::ThreadCapabilitiesReadResponse; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index e211732720..2324d8acd0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -409,6 +409,12 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) fn thread_capabilities_read(&self) -> ThreadCapabilitiesReadResponse { + ThreadCapabilitiesReadResponse { + fork_disable_multi_agent_tools: true, + } + } + pub(crate) async fn thread_resume( &self, request_id: ConnectionRequestId, @@ -3341,7 +3347,7 @@ impl ThreadRequestProcessor { .. } = self .thread_manager - .fork_thread_from_history( + .fork_thread_from_history_with_runtime_override( ForkSnapshot::Interrupted, config, InitialHistory::Resumed(ResumedHistory { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 0f3f0f54fd..b2e6ddc0b9 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1132,7 +1132,6 @@ impl TurnRequestProcessor { history: parent_history.items, rollout_path: parent_thread.rollout_path(), }), - MultiAgentRuntimeOverride::Inherit, /*thread_source*/ None, self.request_trace_context(request_id).await, ) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 90ba837bb2..f7889b8b7b 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -864,15 +864,8 @@ impl ThreadManager { { let snapshot = snapshot.into(); let history = self.initial_history_from_rollout_path(path).await?; - self.fork_thread_from_history( - snapshot, - config, - history, - MultiAgentRuntimeOverride::Inherit, - thread_source, - parent_trace, - ) - .await + self.fork_thread_from_history(snapshot, config, history, thread_source, parent_trace) + .await } async fn initial_history_from_rollout_path( @@ -895,6 +888,30 @@ impl ThreadManager { /// Fork an existing thread from already-loaded store history. pub async fn fork_thread_from_history( + &self, + snapshot: S, + config: Config, + history: InitialHistory, + thread_source: Option, + parent_trace: Option, + ) -> CodexResult + where + S: Into, + { + self.fork_thread_from_history_with_runtime_override( + snapshot, + config, + history, + MultiAgentRuntimeOverride::Inherit, + thread_source, + parent_trace, + ) + .await + } + + /// Fork an existing thread from already-loaded store history with an + /// explicit multi-agent runtime override. + pub async fn fork_thread_from_history_with_runtime_override( &self, snapshot: S, config: Config, diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 4cd6c63f44..b4898ea827 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -199,7 +199,6 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { history: source_items.clone(), rollout_path: None, }), - codex_core::MultiAgentRuntimeOverride::Inherit, /*thread_source*/ None, /*parent_trace*/ None, ) diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index b887f04db3..afd5ceaf54 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -186,7 +186,7 @@ pub(crate) struct AppServerSession { default_model: Option, available_models: Vec, rejected_fork_thread_ids: HashSet, - disabled_forks_unsupported: bool, + disabled_fork_support: DisabledForkSupport, external_agent_config_import_completion_pending: AtomicBool, } @@ -202,6 +202,13 @@ pub(crate) enum MultiAgentToolsOverride { Disabled, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DisabledForkSupport { + Unknown, + Supported, + Unsupported, +} + impl ThreadParamsMode { fn model_provider_from_config(self, config: &Config) -> Option { match self { @@ -229,6 +236,10 @@ pub(crate) enum TurnPermissionsOverride { impl AppServerSession { pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self { + let disabled_fork_support = match &client { + AppServerClient::InProcess(_) => DisabledForkSupport::Supported, + AppServerClient::Remote(_) => DisabledForkSupport::Unknown, + }; Self { client, next_request_id: 1, @@ -238,7 +249,7 @@ impl AppServerSession { default_model: None, available_models: Vec::new(), rejected_fork_thread_ids: HashSet::new(), - disabled_forks_unsupported: false, + disabled_fork_support, external_agent_config_import_completion_pending: AtomicBool::new(false), } } @@ -499,9 +510,27 @@ impl AppServerSession { thread_id: ThreadId, multi_agent_tools: MultiAgentToolsOverride, ) -> Result { - if multi_agent_tools == MultiAgentToolsOverride::Disabled && self.disabled_forks_unsupported - { - return Err(color_eyre::eyre::eyre!(DISABLED_FORK_UNSUPPORTED_MESSAGE)); + if multi_agent_tools == MultiAgentToolsOverride::Disabled { + if self.disabled_fork_support == DisabledForkSupport::Unknown { + let request_id = self.next_request_id(); + self.disabled_fork_support = if self + .client + .supports_thread_fork_disable_multi_agent_tools(request_id) + .await + .map_err(|err| { + bootstrap_request_error( + "thread/capabilities/read failed before TUI fork", + err, + ) + })? { + DisabledForkSupport::Supported + } else { + DisabledForkSupport::Unsupported + }; + } + if self.disabled_fork_support == DisabledForkSupport::Unsupported { + return Err(color_eyre::eyre::eyre!(DISABLED_FORK_UNSUPPORTED_MESSAGE)); + } } let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(&config); @@ -524,10 +553,14 @@ impl AppServerSession { if multi_agent_tools == MultiAgentToolsOverride::Disabled && !response.multi_agent_tools_disabled { - self.disabled_forks_unsupported = true; + self.disabled_fork_support = DisabledForkSupport::Unsupported; if let Ok(thread_id) = ThreadId::from_string(&response.thread.id) { self.quarantine_rejected_fork(thread_id); - let _ = self.thread_unsubscribe(thread_id).await; + if let Err(err) = self.thread_unsubscribe(thread_id).await { + tracing::warn!( + "failed to unsubscribe rejected multi-agent-enabled fork {thread_id}: {err}" + ); + } } return Err(color_eyre::eyre::eyre!(DISABLED_FORK_UNSUPPORTED_MESSAGE)); } @@ -2349,6 +2382,38 @@ mod tests { assert!(params.disable_multi_agent_tools); } + #[tokio::test] + async fn unsupported_disabled_fork_is_rejected_before_creating_a_thread() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("embedded app server"); + app_server.disabled_fork_support = DisabledForkSupport::Unsupported; + let before = app_server + .thread_loaded_list(ThreadLoadedListParams::default()) + .await + .expect("loaded threads before rejected fork"); + + let error = app_server + .fork_thread(config, ThreadId::new(), MultiAgentToolsOverride::Disabled) + .await + .expect_err("unsupported disabled fork should fail"); + + assert!( + error + .to_string() + .contains(DISABLED_FORK_UNSUPPORTED_MESSAGE) + ); + assert_eq!( + app_server + .thread_loaded_list(ThreadLoadedListParams::default()) + .await + .expect("loaded threads after rejected fork"), + before + ); + } + #[tokio::test] async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() { let temp_dir = tempfile::tempdir().expect("tempdir");