From 5119680f85ed01fe039ee8fba0245de24f3a5e37 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 5 May 2026 20:14:18 -0700 Subject: [PATCH 01/30] feat: Add plugin share access controls (#21124) Extends `plugin/share/save` to accept optional discoverability and shareTargets while uploading plugin contents, and adds `plugin/share/updateTargets` for share-only target updates without re-uploading. --- .../schema/json/ClientRequest.json | 92 +++++++ .../codex_app_server_protocol.schemas.json | 129 ++++++++++ .../codex_app_server_protocol.v2.schemas.json | 129 ++++++++++ .../schema/json/v2/PluginShareSaveParams.json | 50 ++++ .../v2/PluginShareUpdateTargetsParams.json | 45 ++++ .../v2/PluginShareUpdateTargetsResponse.json | 45 ++++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/PluginShareDiscoverability.ts | 5 + .../typescript/v2/PluginSharePrincipal.ts | 6 + .../typescript/v2/PluginSharePrincipalType.ts | 5 + .../typescript/v2/PluginShareSaveParams.ts | 4 +- .../schema/typescript/v2/PluginShareTarget.ts | 6 + .../v2/PluginShareUpdateTargetsParams.ts | 6 + .../v2/PluginShareUpdateTargetsResponse.ts | 6 + .../schema/typescript/v2/index.ts | 6 + .../src/protocol/common.rs | 5 + .../src/protocol/v2/plugin.rs | 64 +++++ .../src/protocol/v2/tests.rs | 62 +++++ codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 6 + .../src/request_processors/plugins.rs | 117 +++++++++ .../app-server/tests/suite/v2/plugin_share.rs | 228 ++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 7 + codex-rs/core-plugins/src/remote/share.rs | 75 ++++++ .../core-plugins/src/remote/share/tests.rs | 102 ++++++++ 25 files changed, 1206 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 75df89211d..35efe8695b 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2197,11 +2197,37 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListParams": { "type": "object" }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -2210,6 +2236,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2217,6 +2252,39 @@ ], "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "type": "object" + }, "PluginSkillReadParams": { "properties": { "remoteMarketplaceName": { @@ -5177,6 +5245,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "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 922ca49a5c..881380bf19 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 @@ -810,6 +810,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -12479,6 +12503,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -12525,9 +12557,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -12536,6 +12605,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -12561,6 +12639,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$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 8382944c8e..109294746d 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 @@ -1569,6 +1569,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -9090,6 +9114,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -9136,9 +9168,46 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -9147,6 +9216,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -9172,6 +9250,57 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json index ee1ae48730..c269223068 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -4,9 +4,50 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" } }, "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -15,6 +56,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json new file mode 100644 index 0000000000..080ec980a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "principalId", + "principalType" + ], + "type": "object" + } + }, + "properties": { + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json new file mode 100644 index 0000000000..28fcb665ba --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + } + }, + "required": [ + "name", + "principalId", + "principalType" + ], + "type": "object" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + } + }, + "properties": { + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "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 3484819a45..1c03d2eb37 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -51,6 +51,7 @@ import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; +import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams"; import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; @@ -81,4 +82,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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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": "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/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "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": "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/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/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "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": "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/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/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "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": "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/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/PluginShareDiscoverability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts new file mode 100644 index 0000000000..8c2242163b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts @@ -0,0 +1,5 @@ +// 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 PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts new file mode 100644 index 0000000000..9e0ecc48e7 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts new file mode 100644 index 0000000000..e54c129cbf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts @@ -0,0 +1,5 @@ +// 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 PluginSharePrincipalType = "user" | "group" | "workspace"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts index d2011984e3..c8df0d6c1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts @@ -2,5 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginShareTarget } from "./PluginShareTarget"; -export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, }; +export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts new file mode 100644 index 0000000000..fd1969087f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts new file mode 100644 index 0000000000..53ef2b3025 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginShareTarget } from "./PluginShareTarget"; + +export type PluginShareUpdateTargetsParams = { remotePluginId: string, shareTargets: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts new file mode 100644 index 0000000000..3d6f6e9ee0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts @@ -0,0 +1,6 @@ +// 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. +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareUpdateTargetsResponse = { principals: Array, }; 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 4998cdc710..6901e2a040 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -285,11 +285,17 @@ export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; +export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; +export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; export type { PluginSkillReadParams } from "./PluginSkillReadParams"; export type { PluginSkillReadResponse } from "./PluginSkillReadResponse"; export type { PluginSource } from "./PluginSource"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9d425d8ab4..ae687a21b2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -628,6 +628,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareSaveResponse, }, + PluginShareUpdateTargets => "plugin/share/updateTargets" { + params: v2::PluginShareUpdateTargetsParams, + serialization: global("config"), + response: v2::PluginShareUpdateTargetsResponse, + }, PluginShareList => "plugin/share/list" { params: v2::PluginShareListParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index a6c22bfc9f..3a7651724d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -194,6 +194,10 @@ pub struct PluginShareSaveParams { pub plugin_path: AbsolutePathBuf, #[ts(optional = nullable)] pub remote_plugin_id: Option, + #[ts(optional = nullable)] + pub discoverability: Option, + #[ts(optional = nullable)] + pub share_targets: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -204,6 +208,21 @@ pub struct PluginShareSaveResponse { pub share_url: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsParams { + pub remote_plugin_id: String, + pub share_targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsResponse { + pub principals: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -237,6 +256,51 @@ pub struct PluginShareListItem { pub local_plugin_path: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareDiscoverability { + #[serde(rename = "LISTED")] + #[ts(rename = "LISTED")] + Listed, + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalType { + #[serde(rename = "user")] + #[ts(rename = "user")] + User, + #[serde(rename = "group")] + #[ts(rename = "group")] + Group, + #[serde(rename = "workspace")] + #[ts(rename = "workspace")] + Workspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareTarget { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSharePrincipal { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 719a933f22..dba9c24108 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2981,11 +2981,15 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { serde_json::to_value(PluginShareSaveParams { plugin_path: plugin_path.clone(), remote_plugin_id: None, + discoverability: None, + share_targets: None, }) .unwrap(), json!({ "pluginPath": plugin_path_json, "remotePluginId": null, + "discoverability": null, + "shareTargets": null, }), ); @@ -2993,11 +2997,33 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { serde_json::to_value(PluginShareSaveParams { plugin_path, remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + discoverability: Some(PluginShareDiscoverability::Private), + share_targets: Some(vec![ + PluginShareTarget { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + PluginShareTarget { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), }) .unwrap(), json!({ "pluginPath": plugin_path_json, "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], }), ); @@ -3013,6 +3039,42 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { }), ); + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_targets: vec![PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }], + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareTargets": [{ + "principalType": "group", + "principalId": "group-1", + }], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + }) + .unwrap(), + json!({ + "principals": [{ + "principalType": "user", + "principalId": "user-1", + "name": "Gavin", + }], + }), + ); + assert_eq!( serde_json::from_value::(json!({})).unwrap(), PluginShareListParams {}, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4c1d16eeac..c449217254 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1087,6 +1087,11 @@ impl MessageProcessor { ClientRequest::PluginShareSave { params, .. } => { self.plugin_processor.plugin_share_save(params).await } + ClientRequest::PluginShareUpdateTargets { params, .. } => { + self.plugin_processor + .plugin_share_update_targets(params) + .await + } ClientRequest::PluginShareList { params, .. } => { self.plugin_processor.plugin_share_list(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index be6d559866..8667d67d11 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -115,11 +115,17 @@ use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDeleteParams; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListParams; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveParams; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareTarget; +use codex_app_server_protocol::PluginShareUpdateTargetsParams; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginSkillReadResponse; use codex_app_server_protocol::PluginSource; diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d9f66d65ce..a758e3a582 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -86,6 +86,66 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS } } +fn remote_plugin_share_discoverability( + discoverability: PluginShareDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { + match discoverability { + PluginShareDiscoverability::Listed => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed + } + PluginShareDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted + } + PluginShareDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_share_targets( + targets: Vec, +) -> Vec { + targets + .into_iter() + .map( + |target| codex_core_plugins::remote::RemotePluginShareTarget { + principal_type: match target.principal_type { + PluginSharePrincipalType::User => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User + } + PluginSharePrincipalType::Group => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group + } + PluginSharePrincipalType::Workspace => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace + } + }, + principal_id: target.principal_id, + }, + ) + .collect() +} + +fn plugin_share_principal_from_remote( + principal: codex_core_plugins::remote::RemotePluginSharePrincipal, +) -> PluginSharePrincipal { + PluginSharePrincipal { + principal_type: match principal.principal_type { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User => { + PluginSharePrincipalType::User + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => { + PluginSharePrincipalType::Group + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => { + PluginSharePrincipalType::Workspace + } + }, + principal_id: principal.principal_id, + name: principal.name, + } +} + impl PluginRequestProcessor { pub(crate) fn new( auth_manager: Arc, @@ -141,6 +201,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_share_update_targets( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_update_targets_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_share_list( &self, params: PluginShareListParams, @@ -570,22 +639,34 @@ impl PluginRequestProcessor { let PluginShareSaveParams { plugin_path, remote_plugin_id, + discoverability, + share_targets, } = params; if let Some(remote_plugin_id) = remote_plugin_id.as_ref() && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) { return Err(invalid_request("invalid remote plugin id")); } + if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) { + return Err(invalid_request( + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets", + )); + } let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; + let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { + discoverability: discoverability.map(remote_plugin_share_discoverability), + share_targets: share_targets.map(remote_plugin_share_targets), + }; let result = codex_core_plugins::remote::save_remote_plugin_share( &remote_plugin_service_config, auth.as_ref(), config.codex_home.as_path(), &plugin_path, remote_plugin_id.as_deref(), + access_policy, ) .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; @@ -597,6 +678,42 @@ impl PluginRequestProcessor { }) } + async fn plugin_share_update_targets_response( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareUpdateTargetsParams { + remote_plugin_id, + share_targets, + } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::update_remote_plugin_share_targets( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + remote_plugin_share_targets(share_targets), + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets") + })?; + self.clear_plugin_related_caches(); + Ok(PluginShareUpdateTargetsResponse { + principals: result + .principals + .into_iter() + .map(plugin_share_principal_from_remote) + .collect(), + }) + } + async fn plugin_share_list_response( &self, _params: PluginShareListParams, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 604935b6e5..8175ecd459 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; @@ -14,7 +15,10 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginShareDeleteResponse; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -174,6 +178,154 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_forwards_access_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path, + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + { + "principalType": "workspace", + "principalId": "workspace-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "remotePluginId": "plugins_123", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share targets" + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { let codex_home = TempDir::new()?; @@ -250,6 +402,82 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareUpdateTargetsResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }], + } + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 331362d626..397f0ad747 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -29,10 +29,17 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; +pub use share::RemotePluginShareAccessPolicy; +pub use share::RemotePluginShareDiscoverability; +pub use share::RemotePluginSharePrincipal; +pub use share::RemotePluginSharePrincipalType; pub use share::RemotePluginShareSaveResult; +pub use share::RemotePluginShareTarget; +pub use share::RemotePluginShareUpdateTargetsResult; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; pub use share::save_remote_plugin_share; +pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 58df033cfb..0a254a1736 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -26,6 +26,46 @@ pub struct RemotePluginShareSaveResult { pub share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemotePluginShareAccessPolicy { + pub discoverability: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareDiscoverability { + Listed, + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalType { + User, + Group, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePluginShareTarget { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginSharePrincipal { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareUpdateTargetsResult { + pub principals: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct RemoteWorkspacePluginUploadUrlRequest<'a> { filename: &'a str, @@ -46,6 +86,10 @@ struct RemoteWorkspacePluginUploadUrlResponse { struct RemoteWorkspacePluginCreateRequest { file_id: String, etag: String, + #[serde(skip_serializing_if = "Option::is_none")] + discoverability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_targets: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -54,12 +98,23 @@ struct RemoteWorkspacePluginCreateResponse { share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemotePluginShareUpdateTargetsRequest { + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginShareUpdateTargetsResponse { + principals: Vec, +} + pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, + access_policy: RemotePluginShareAccessPolicy, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); @@ -89,6 +144,8 @@ pub async fn save_remote_plugin_share( RemoteWorkspacePluginCreateRequest { file_id: upload.file_id, etag, + discoverability: access_policy.discoverability, + share_targets: access_policy.share_targets, }, ) .await?; @@ -173,6 +230,24 @@ pub async fn delete_remote_plugin_share( Ok(()) } +pub async fn update_remote_plugin_share_targets( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, + targets: Vec, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/public/plugins/{remote_plugin_id}/shares"); + let client = build_reqwest_client(); + let request = authenticated_request(client.put(&url), auth)? + .json(&RemotePluginShareUpdateTargetsRequest { targets }); + let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + Ok(RemotePluginShareUpdateTargetsResult { + principals: response.principals, + }) +} + async fn fetch_created_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 65a989c26e..31ad5dd3fc 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -202,6 +202,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", + "discoverability": "PRIVATE", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "workspace", + "principal_id": "workspace-1", + }, + ], }))) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "plugin_id": "plugins_123", @@ -217,6 +228,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { codex_home.path(), &plugin_path, /*remote_plugin_id*/ None, + RemotePluginShareAccessPolicy { + discoverability: Some(RemotePluginShareDiscoverability::Private), + share_targets: Some(vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Workspace, + principal_id: "workspace-1".to_string(), + }, + ]), + }, ) .await .unwrap(); @@ -354,6 +378,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { codex_home.path(), &plugin_path, Some("plugins_123"), + RemotePluginShareAccessPolicy::default(), ) .await .unwrap(); @@ -367,6 +392,83 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { ); } +#[tokio::test] +async fn update_remote_plugin_share_targets_updates_targets() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/public/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + }, + { + "principal_type": "group", + "principal_id": "group-1", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "name": "Gavin", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "name": "Engineering", + }, + ], + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + }, + ], + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + name: "Gavin".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + name: "Engineering".to_string(), + }, + ], + } + ); +} + #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); From 8ef31894dcccdad047f16b03b4df6a943dbabacd Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Tue, 5 May 2026 21:05:00 -0700 Subject: [PATCH 02/30] app-server: align dynamic tool identifiers with Responses API (#20724) ## Why Codex currently accepts dynamic tool names and namespaces that the upstream Responses function-tool path does not actually support. In practice, that means app-server can register a dynamic tool successfully and only discover later that the LLM-facing tool contract will reject or mishandle it. This PR tightens the app-server-side dynamic tool contract to match the Responses API before we stack dynamic tool hook support on top of it. ## What changed - validate dynamic tool `name` against the Responses function-tool identifier contract: `^[a-zA-Z0-9_-]+$`, length `1..128` - validate dynamic tool `namespace` the same way, with the Responses namespace length limit `1..64` - reject namespaces that collide with the always-reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, and `submodel_delegator` - escape invalid identifiers in error messages so control characters do not spill raw into logs or client-visible error text - document the tightened dynamic tool identifier contract in `codex-rs/app-server/README.md` - add both unit coverage for the validator and an app-server integration test that rejects a `thread/start` request with Responses-incompatible dynamic tool identifiers ## Verification - `cargo test -p codex-app-server validate_dynamic_tools_` - `cargo test -p codex-app-server --test all thread_start_rejects_dynamic_tools_not_supported_by_responses` --- codex-rs/app-server/README.md | 6 + .../request_processors/thread_processor.rs | 62 +++++++++- .../thread_processor_tests.rs | 114 ++++++++++++++++++ .../tests/suite/v2/dynamic_tools.rs | 40 ++++++ 4 files changed, 221 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 115fe1c1d2..60ff4573aa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1393,6 +1393,12 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: 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 321d1703dc..147c4cd721 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -184,6 +184,53 @@ fn has_model_resume_override( } fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + let mut seen = HashSet::new(); for tool in tools { let name = tool.name.trim(); @@ -193,9 +240,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if name != tool.name { return Err(format!( "dynamic tool name has leading/trailing whitespace: {}", - tool.name + escape_identifier_for_error(&tool.name), )); } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; if name == "mcp" || name.starts_with("mcp__") { return Err(format!("dynamic tool name is reserved: {name}")); } @@ -209,13 +257,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { if Some(namespace) != tool.namespace.as_deref() { return Err(format!( "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), )); } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; if namespace == "mcp" || namespace.starts_with("mcp__") { return Err(format!( "dynamic tool namespace is reserved for {name}: {namespace}" )); } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } } if !seen.insert((namespace, name)) { if let Some(namespace) = namespace { diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 6bcbda478c..01c09de43b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -156,6 +156,22 @@ mod thread_processor_behavior_tests { validate_dynamic_tools(&tools).expect("valid schema"); } + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + #[test] fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { let tools = vec![ @@ -260,6 +276,104 @@ mod thread_processor_behavior_tests { assert!(err.contains("reserved"), "unexpected error: {err}"); } + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + #[test] fn summary_from_stored_thread_preserves_millisecond_precision() { let created_at = diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 7ee21a2068..b357e139db 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + /// Exercises the full dynamic tool call path (server request, client response, model output). #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { From 9f06d171e23fdb8efcb8ee8c05d12c76fb472529 Mon Sep 17 00:00:00 2001 From: aaronl-openai Date: Tue, 5 May 2026 21:09:28 -0700 Subject: [PATCH 03/30] Preserve session MCP config on refresh (#21055) # Overview MCP refreshes were rebuilding active threads from fresh disk-backed config only, which dropped thread-start session overlays such as app-injected MCP servers. This keeps refreshes current with disk config while preserving the thread-local config that only the active thread knows about. # Changes - Rebuild refreshed config per active thread using that thread's current `cwd`, rather than fanning out one app-server config to every thread. - Preserve each thread's `SessionFlags` layer while replacing reloadable config layers with freshly loaded config, then derive the MCP refresh payload from the rebuilt result. - Move MCP refresh orchestration into app-server so manual refreshes fail loudly while background refreshes remain best-effort, and route plugin-triggered refreshes through the same per-thread reload path. - Add regression coverage for session overlays, fresh project config, plugin-derived MCP config, current requirements, and strict vs best-effort refresh behavior. # Verification - Passed focused Rust coverage for the thread-config rebuild behavior and deferred MCP refresh flow, plus `cargo test -p codex-app-server --lib`. - Verified end to end in the Codex dev app against the locally built CLI: registered an MCP via thread config, verified that it could be used successfully before refresh, manually triggered MCP refresh, and verified that it continued to be available afterward. --- codex-rs/app-server/src/config_manager.rs | 15 + codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/mcp_refresh.rs | 241 ++++++++++++++ codex-rs/app-server/src/message_processor.rs | 2 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/account_processor.rs | 16 +- .../src/request_processors/mcp_processor.rs | 43 +-- .../src/request_processors/plugins.rs | 42 +-- codex-rs/core/src/config/config_tests.rs | 302 ++++++++++++++++++ codex-rs/core/src/config/mod.rs | 60 ++++ codex-rs/core/src/thread_manager.rs | 22 -- 11 files changed, 652 insertions(+), 93 deletions(-) create mode 100644 codex-rs/app-server/src/mcp_refresh.rs diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index ba11205b7a..030829fa4b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -140,6 +140,21 @@ impl ConfigManager { .await } + pub(crate) async fn load_latest_config_for_thread( + &self, + thread_config: &Config, + ) -> std::io::Result { + let refreshed_config = self + .load_latest_config(Some(thread_config.cwd.to_path_buf())) + .await?; + let mut config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + pub(crate) async fn load_default_config(&self) -> std::io::Result { let mut config = Config::load_default_with_cli_overrides_for_codex_home( self.codex_home.clone(), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4013bbe76b..cf6a9e890e 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -85,6 +85,7 @@ mod filters; mod fs_watch; mod fuzzy_file_search; pub mod in_process; +mod mcp_refresh; mod message_processor; mod models; mod outgoing_message; diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs new file mode 100644 index 0000000000..93dc02f7c3 --- /dev/null +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -0,0 +1,241 @@ +use crate::config_manager::ConfigManager; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; +use std::io; +use std::sync::Arc; +use tracing::warn; + +pub(crate) async fn queue_strict_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) -> io::Result<()> { + config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await?; + let mut refreshes = Vec::new(); + for thread_id in thread_manager.list_thread_ids().await { + let thread = thread_manager + .get_thread(thread_id) + .await + .map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?; + let config = + build_refresh_config(thread_manager, config_manager, thread.config().await).await?; + refreshes.push((thread_id, thread, config)); + } + for (thread_id, thread, config) in refreshes { + queue_refresh(thread_id, thread, config).await?; + } + Ok(()) +} + +pub(crate) async fn queue_best_effort_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) { + for thread_id in thread_manager.list_thread_ids().await { + let thread = match thread_manager.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + warn!("failed to load thread {thread_id} for MCP refresh: {err}"); + continue; + } + }; + let config = + match build_refresh_config(thread_manager, config_manager, thread.config().await).await + { + Ok(config) => config, + Err(err) => { + warn!("failed to build MCP refresh config for thread {thread_id}: {err}"); + continue; + } + }; + if let Err(err) = queue_refresh(thread_id, thread, config).await { + warn!("{err}"); + } + } +} + +async fn build_refresh_config( + thread_manager: &ThreadManager, + config_manager: &ConfigManager, + thread_config: Arc, +) -> io::Result { + let config = config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await?; + let mcp_servers = thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + Ok(McpServerRefreshConfig { + mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + ) + .map_err(io::Error::other)?, + }) +} + +async fn queue_refresh( + thread_id: ThreadId, + thread: Arc, + config: McpServerRefreshConfig, +) -> io::Result<()> { + thread + .submit(Op::RefreshMcpServers { config }) + .await + .map(|_| ()) + .map_err(|err| { + io::Error::other(format!( + "failed to queue MCP refresh for thread {thread_id}: {err}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use codex_arg0::Arg0DispatchPaths; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::ThreadConfigContext; + use codex_config::ThreadConfigLoadError; + use codex_config::ThreadConfigLoadErrorCode; + use codex_config::ThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_core::agent_graph_store_from_state_db; + use codex_core::config::ConfigOverrides; + use codex_core::init_state_db_from_config; + use codex_core::thread_store_from_config; + use codex_exec_server::EnvironmentManager; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[tokio::test] + async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?; + + let err = queue_strict_refresh(&thread_manager, &config_manager) + .await + .expect_err("strict refresh should fail"); + + assert_eq!(err.to_string(), "failed to load refresh config"); + Ok(()) + } + + #[tokio::test] + async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?; + + queue_best_effort_refresh(&thread_manager, &config_manager).await; + + assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1); + assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1); + Ok(()) + } + + async fn refresh_test_state() -> anyhow::Result<( + TempDir, + Arc, + ConfigManager, + Arc, + )> { + let temp_dir = TempDir::new()?; + let good_cwd = temp_dir.path().join("good"); + let bad_cwd = temp_dir.path().join("bad"); + std::fs::create_dir_all(&good_cwd)?; + std::fs::create_dir_all(&bad_cwd)?; + + let initial_config_manager = + ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf()); + let good_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(good_cwd.clone()), + ) + .await?; + let bad_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(bad_cwd.clone()), + ) + .await?; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); + let state_db = init_state_db_from_config(&good_config) + .await + .expect("refresh tests require state db"); + let thread_store = thread_store_from_config(&good_config, state_db.clone()); + let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let thread_manager = Arc::new(ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + )); + thread_manager.start_thread(good_config).await?; + thread_manager.start_thread(bad_config).await?; + + let loader = Arc::new(CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf::try_from(good_cwd)?, + bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?, + good_loads: AtomicUsize::new(0), + bad_loads: AtomicUsize::new(0), + }); + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + loader.clone(), + ); + + Ok((temp_dir, thread_manager, config_manager, loader)) + } + + struct CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf, + bad_cwd: AbsolutePathBuf, + good_loads: AtomicUsize, + bad_loads: AtomicUsize, + } + + #[async_trait] + impl ThreadConfigLoader for CountingThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + if context.cwd.as_ref() == Some(&self.good_cwd) { + self.good_loads.fetch_add(1, Ordering::Relaxed); + } + if context.cwd.as_ref() == Some(&self.bad_cwd) { + self.bad_loads.fetch_add(1, Ordering::Relaxed); + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Internal, + /*status_code*/ None, + "failed to load refresh config", + )); + } + Ok(Vec::new()) + } + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index c449217254..e0cc3bd176 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -416,7 +416,7 @@ impl MessageProcessor { if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. let on_effective_plugins_changed = - plugin_processor.effective_plugins_changed_callback((*config).clone()); + plugin_processor.effective_plugins_changed_callback(); thread_manager .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8667d67d11..9f419d7006 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -361,7 +361,6 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RealtimeVoicesList; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 2609e25ac4..c73d6700e7 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -168,7 +168,7 @@ impl AccountRequestProcessor { { Ok(config) => { let refresh_thread_manager = Arc::clone(thread_manager); - let refresh_config = config.clone(); + let refresh_config_manager = config_manager.clone(); thread_manager .plugins_manager() .maybe_start_remote_installed_plugins_cache_refresh( @@ -177,7 +177,7 @@ impl AccountRequestProcessor { Some(Arc::new(move || { Self::spawn_effective_plugins_changed_task( Arc::clone(&refresh_thread_manager), - refresh_config.clone(), + refresh_config_manager.clone(), ); })), ); @@ -190,19 +190,17 @@ impl AccountRequestProcessor { } } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 22d43d87a2..243506f6af 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -77,8 +77,9 @@ impl McpRequestProcessor { &self, _params: Option<()>, ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?; + crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) + .await + .map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?; Ok(McpServerRefreshResponse {}) } @@ -108,44 +109,6 @@ impl McpRequestProcessor { Ok((thread_id, thread)) } - pub(super) async fn queue_mcp_server_refresh_for_config( - thread_manager: &Arc, - config: &Config, - ) -> Result<(), JSONRPCErrorError> { - let configured_servers = thread_manager - .mcp_manager() - .configured_servers(config) - .await; - let mcp_servers = match serde_json::to_value(configured_servers) { - Ok(value) => value, - Err(err) => { - return Err(internal_error(format!( - "failed to serialize MCP servers: {err}" - ))); - } - }; - - let mcp_oauth_credentials_store_mode = - match serde_json::to_value(config.mcp_oauth_credentials_store_mode) { - Ok(value) => value, - Err(err) => { - return Err(internal_error(format!( - "failed to serialize MCP OAuth credentials store mode: {err}" - ))); - } - }; - - let refresh_config = McpServerRefreshConfig { - mcp_servers, - mcp_oauth_credentials_store_mode, - }; - - // Refresh requests are queued per thread; each thread rebuilds MCP connections on its next - // active turn to avoid work for threads that never resume. - thread_manager.refresh_mcp_servers(refresh_config).await; - Ok(()) - } - async fn mcp_server_oauth_login_response( &self, params: McpServerOauthLoginParams, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index a758e3a582..7d542edf39 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -246,33 +246,35 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } - pub(crate) fn effective_plugins_changed_callback( - &self, - config: Config, - ) -> Arc { + pub(crate) fn effective_plugins_changed_callback(&self) -> Arc { let thread_manager = Arc::clone(&self.thread_manager); + let config_manager = self.config_manager.clone(); Arc::new(move || { - Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone()); + Self::spawn_effective_plugins_changed_task( + Arc::clone(&thread_manager), + config_manager.clone(), + ); }) } - fn on_effective_plugins_changed(&self, config: Config) { - Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config); + fn on_effective_plugins_changed(&self) { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&self.thread_manager), + self.config_manager.clone(), + ); } - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { tokio::spawn(async move { thread_manager.plugins_manager().clear_cache(); thread_manager.skills_manager().clear_cache(); if thread_manager.list_thread_ids().await.is_empty() { return; } - if let Err(err) = - McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config) - .await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; }); } @@ -342,7 +344,7 @@ impl PluginRequestProcessor { &plugins_input, auth.clone(), &roots, - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let config_for_marketplace_listing = plugins_input.clone(); @@ -840,7 +842,7 @@ impl PluginRequestProcessor { } }; - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; if !plugin_mcp_servers.is_empty() { @@ -951,7 +953,7 @@ impl PluginRequestProcessor { .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); let mut plugin_metadata = @@ -1144,7 +1146,7 @@ impl PluginRequestProcessor { .await .map_err(Self::plugin_uninstall_error)?; match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self.on_effective_plugins_changed(config), + Ok(_) => self.on_effective_plugins_changed(), Err(err) => { warn!( "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" @@ -1245,12 +1247,12 @@ impl PluginRequestProcessor { ) { let plugins_manager = self.thread_manager.plugins_manager(); if plugins_manager.clear_remote_installed_plugins_cache() { - self.on_effective_plugins_changed(config.clone()); + self.on_effective_plugins_changed(); } plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( &config.plugins_config_input(), auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), + Some(self.effective_plugins_changed_callback()), ); } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c35a3767cb..aed305552d 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -2845,6 +2846,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { ); } +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let project_dot_codex = + AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path()); + let mcp_requirements = BTreeMap::from([ + ( + "session_overrides_user".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "session-command".to_string(), + }, + }, + ), + ( + "managed_overrides_session".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "managed-command".to_string(), + }, + }, + ), + ( + "fresh_global".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-global-command".to_string(), + }, + }, + ), + ( + "fresh_project".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-project-command".to_string(), + }, + }, + ), + ]); + let requirements_toml = codex_config::ConfigRequirementsToml { + mcp_servers: Some(mcp_requirements.clone()), + ..Default::default() + }; + let requirements = codex_config::ConfigRequirements { + mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)), + ..Default::default() + }; + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "new-user-command" + [mcp_servers.managed_overrides_session] + command = "new-user-command" + [mcp_servers.fresh_global] + command = "fresh-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex.clone(), + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "fresh-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "managed-command" + } + .into(), + ), + ], + requirements, + requirements_toml, + ) + .map_err(std::io::Error::other)?; + let refreshed_toml = refreshed_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "old-user-command" + [mcp_servers.managed_overrides_session] + command = "old-user-command" + [mcp_servers.fresh_global] + command = "old-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "old-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::SessionFlags, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "session-command" + [mcp_servers.managed_overrides_session] + command = "session-command" + [mcp_servers.blocked_session] + command = "blocked-session-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "old-managed-command" + } + .into(), + ), + ], + Default::default(), + Default::default(), + ) + .map_err(std::io::Error::other)?; + let thread_toml = thread_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + + assert_eq!( + config.mcp_servers.get(), + &HashMap::from([ + ( + "session_overrides_user".to_string(), + stdio_mcp("session-command"), + ), + ( + "managed_overrides_session".to_string(), + stdio_mcp("managed-command"), + ), + ( + "fresh_global".to_string(), + stdio_mcp("fresh-global-command"), + ), + ( + "fresh_project".to_string(), + stdio_mcp("fresh-project-command"), + ), + ( + "blocked_session".to_string(), + McpServerConfig { + enabled: false, + disabled_reason: Some(McpServerDisabledReason::Requirements { + source: RequirementSource::Unknown, + }), + ..stdio_mcp("blocked-session-command") + }, + ), + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() +-> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + )?; + + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [features] + plugins = true + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + toml::toml! { + [features] + plugins = false + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://sample.example/mcp")) + ); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index cfe2100d49..657a0413c9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1130,6 +1130,62 @@ impl Config { } } + pub async fn rebuild_preserving_session_layers( + &self, + refreshed_config: &Config, + ) -> std::io::Result { + let mut layers = refreshed_config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| !is_session_layer(&layer.name)) + .cloned() + .collect::>(); + layers.extend( + self.config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| is_session_layer(&layer.name)) + .cloned(), + ); + layers.sort_by_key(|layer| layer.name.precedence()); + + let config_layer_stack = ConfigLayerStack::new( + layers, + refreshed_config.config_layer_stack.requirements().clone(), + refreshed_config + .config_layer_stack + .requirements_toml() + .clone(), + )? + .with_user_and_project_exec_policy_rules_ignored( + refreshed_config + .config_layer_stack + .ignore_user_and_project_exec_policy_rules(), + ); + let cfg: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(self.cwd.to_path_buf()), + ..Default::default() + }, + refreshed_config.codex_home.clone(), + config_layer_stack, + ) + .await + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1680,6 +1736,10 @@ fn thread_store_config( } } +fn is_session_layer(source: &ConfigLayerSource) -> bool { + matches!(source, ConfigLayerSource::SessionFlags) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 03a3ec92fc..124f0729ef 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -42,7 +42,6 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; @@ -526,27 +525,6 @@ impl ThreadManager { self.state.list_thread_ids().await } - pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) { - let threads = self - .state - .threads - .read() - .await - .values() - .cloned() - .collect::>(); - for thread in threads { - if let Err(err) = thread - .submit(Op::RefreshMcpServers { - config: refresh_config.clone(), - }) - .await - { - warn!("failed to request MCP server refresh: {err}"); - } - } - } - pub fn subscribe_thread_created(&self) -> broadcast::Receiver { self.state.thread_created_tx.subscribe() } From 41505bcea28255ad8849e094c8b7051fed07956a Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 5 May 2026 21:23:42 -0700 Subject: [PATCH 04/30] [mcp] Return Accept early per feedback. (#21277) - [x] Return Accept early when auto_deny is enabled per feedback. --- codex-rs/core/src/session/mcp.rs | 14 ++++++++++ codex-rs/core/src/session/tests.rs | 45 ++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 59bb4f189a..89c368d0c2 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -11,6 +11,20 @@ impl Session { request_id: RequestId, params: McpServerElicitationRequestParams, ) -> Option { + if self + .services + .mcp_connection_manager + .read() + .await + .elicitations_auto_deny() + { + return Some(ElicitationResponse { + action: codex_rmcp_client::ElicitationAction::Accept, + content: Some(serde_json::json!({})), + meta: None, + }); + } + let server_name = params.server_name.clone(); let request = match params.request { McpServerElicitationRequest::Form { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3875f8fb57..60c7278ea0 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -74,6 +74,7 @@ use crate::tools::registry::ToolHandler; use crate::tools::router::ToolCallSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::McpElicitationSchema; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_execpolicy::Decision; @@ -124,6 +125,7 @@ use codex_protocol::protocol::UserMessageEvent; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_rmcp_client::ElicitationAction; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::context_snapshot; @@ -293,6 +295,49 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; } +#[tokio::test] +async fn request_mcp_server_elicitation_auto_accepts_when_auto_deny_is_enabled() { + let (session, turn_context, rx) = make_session_and_context_with_rx().await; + session + .services + .mcp_connection_manager + .read() + .await + .set_elicitations_auto_deny(/*auto_deny*/ true); + + let requested_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + })) + .expect("schema should deserialize"); + let response = session + .request_mcp_server_elicitation( + turn_context.as_ref(), + RequestId::String("request-1".into()), + McpServerElicitationRequestParams { + thread_id: session.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: "codex_apps".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema, + }, + }, + ) + .await; + + assert_eq!( + response, + Some(ElicitationResponse { + action: ElicitationAction::Accept, + content: Some(json!({})), + meta: None, + }) + ); + assert!(rx.try_recv().is_err()); +} + #[tokio::test] async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted() { let (sess, tc, rx) = make_session_and_context_with_rx().await; From 9766d3d51cec885114b6d6c53a02e9efbaf87171 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 5 May 2026 22:22:01 -0700 Subject: [PATCH 05/30] fix(bwrap): emit libcap after standalone archive (#21285) ## Why #21255 added the standalone `codex-bwrap` binary. In the Cargo build, [`pkg_config::probe("libcap")`](https://github.com/openai/codex/blob/a736cb55a2bce57b4c8e5a4fe56f70c2b2ad892b/codex-rs/bwrap/build.rs#L37-L39) emits `-lcap` before [`cc::Build::compile("standalone_bwrap")`](https://github.com/openai/codex/blob/a736cb55a2bce57b4c8e5a4fe56f70c2b2ad892b/codex-rs/bwrap/build.rs#L50-L67) adds the static bwrap archive. The Linux musl link then sees `-lcap -lstandalone_bwrap`; because static archives are resolved left-to-right, `cap_from_name` is still undefined once `standalone_bwrap` introduces that reference. The musl setup already builds `libcap.a` and exposes it through [`libcap.pc`](https://github.com/openai/codex/blob/a736cb55a2bce57b4c8e5a4fe56f70c2b2ad892b/.github/scripts/install-musl-build-tools.sh#L78-L88), so the failure is link ordering rather than a missing dependency. ## What changed - probe `libcap` with `cargo_metadata(false)` so `pkg-config` does not emit its link flags early - emit the discovered `libcap` search paths and libraries after `standalone_bwrap` is compiled, preserving the needed static-link order ## Verification - `cargo test -p codex-bwrap` - `cargo clippy -p codex-bwrap --all-targets` The affected Linux musl release link is exercised by CI, which is the path this fix targets. --- codex-rs/bwrap/build.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs index 40c2712839..d9d87932b2 100644 --- a/codex-rs/bwrap/build.rs +++ b/codex-rs/bwrap/build.rs @@ -35,6 +35,7 @@ fn try_build_bwrap() -> Result<(), String> { let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; let libcap = pkg_config::Config::new() + .cargo_metadata(false) .probe("libcap") .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; @@ -65,6 +66,12 @@ fn try_build_bwrap() -> Result<(), String> { } build.compile("standalone_bwrap"); + for link_path in libcap.link_paths { + println!("cargo:rustc-link-search=native={}", link_path.display()); + } + for lib in libcap.libs { + println!("cargo:rustc-link-lib={lib}"); + } println!("cargo:rustc-cfg=bwrap_available"); Ok(()) } From 22326e263c7b13f64005e3e02fc344dbab61e745 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 5 May 2026 23:33:13 -0700 Subject: [PATCH 06/30] release: bundle bwrap with Linux codex DotSlash artifact (#21312) ## Why #21255 changed the Linux sandbox fallback so Codex can use a bundled `codex-resources/bwrap` executable when no suitable system `bwrap` is available. That lookup is relative to the native Codex executable returned by `std::env::current_exe()`, as implemented in [`bundled_bwrap.rs`](https://github.com/openai/codex/blob/9766d3d51cec885114b6d6c53a02e9efbaf87171/codex-rs/linux-sandbox/src/bundled_bwrap.rs#L83-L93). The release already publishes a separate `bwrap` DotSlash output, but the Linux `codex` DotSlash output still pointed at a single-binary `.zst` payload. Running the `codex` DotSlash manifest only materializes the native `codex` executable; it does not also create sibling files from the separate `bwrap` manifest. The fallback path therefore needs the Linux `codex` DotSlash artifact itself to include the real `bwrap` executable at `codex-resources/bwrap`. ## What changed - stage a Linux primary `codex--bundle.tar.zst` release artifact containing `codex` and `codex-resources/bwrap` - point the Linux `codex` DotSlash outputs at that bundle tarball - leave the standalone `bwrap` DotSlash output in place for consumers that want to fetch `bwrap` directly ## Verification - `jq . .github/dotslash-config.json` - Ruby YAML parse of `.github/workflows/rust-release.yml` --- .github/dotslash-config.json | 4 ++-- .github/workflows/rust-release.yml | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 801e0eb662..a0297c269a 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -11,11 +11,11 @@ "path": "codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl\\.zst$", + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "windows-x86_64": { diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index c1d3aff907..a7700ac924 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -379,6 +379,17 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" + rm -rf "$bundle_root" + mkdir -p "$bundle_root/codex-resources" + cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" + cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" + chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" + tar -C "$bundle_root" -cf - codex codex-resources/bwrap | + zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi @@ -402,7 +413,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -422,8 +433,8 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. + # Upload the per-binary .zst files, .tar.gz equivalents, and any + # prebuilt archives staged above. path: | codex-rs/dist/${{ matrix.target }}/* From f9a907aebe6941e6b800f1f7fcb2fffecf4d7ab1 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 6 May 2026 00:18:00 -0700 Subject: [PATCH 07/30] Support Codex Apps auth elicitations (#19193) ## Summary - request URL-mode MCP elicitations when Codex Apps tool calls fail with connector auth metadata - route Codex Apps auth URL elicitations into the TUI app-link flow ## Test plan - `just fmt` - `cargo test -p codex-core mcp_tool_call::tests` - `cargo test -p codex-mcp` - `cargo test -p codex-tui bottom_pane::app_link_view::tests` - `just fix -p codex-core` - `just fix -p codex-mcp` - `just fix -p codex-tui` Also attempted broader local runs: - `cargo test -p codex-core` fails in unrelated config/request-permission/proxy-sensitive tests under the current Codex Desktop environment. - `cargo test -p codex-tui` fails in unrelated status snapshots/trust-default tests because the ambient environment renders workspace-write/network permission defaults. --- .../app-server/tests/suite/v2/mcp_tool.rs | 128 ++++ codex-rs/codex-mcp/src/auth_elicitation.rs | 347 ++++++++++ codex-rs/codex-mcp/src/connection_manager.rs | 8 + codex-rs/codex-mcp/src/lib.rs | 11 + codex-rs/codex-mcp/src/mcp/mod.rs | 10 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/connectors.rs | 3 + codex-rs/core/src/mcp_tool_call.rs | 143 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 247 +++++++ codex-rs/core/src/session/mcp.rs | 3 + codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/session.rs | 4 + codex-rs/core/tests/suite/plugins.rs | 1 + codex-rs/features/src/lib.rs | 8 + codex-rs/features/src/tests.rs | 10 + codex-rs/tui/src/app.rs | 2 + codex-rs/tui/src/app/tests.rs | 80 +++ codex-rs/tui/src/app/thread_routing.rs | 57 +- codex-rs/tui/src/bottom_pane/app_link_view.rs | 617 +++++++++++++++++- codex-rs/tui/src/bottom_pane/mod.rs | 6 + ...link_view_auth_suggestion_with_reason.snap | 18 + ...app_link_view_generic_url_elicitation.snap | 19 + ..._generic_url_elicitation_confirmation.snap | 17 + codex-rs/tui/src/chatwidget.rs | 44 +- .../tui/src/chatwidget/tests/app_server.rs | 36 + 25 files changed, 1748 insertions(+), 78 deletions(-) create mode 100644 codex-rs/codex-mcp/src/auth_elicitation.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 141761e88a..b805f75ba7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; @@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, diff --git a/codex-rs/codex-mcp/src/auth_elicitation.rs b/codex-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 0000000000..77c7b78c55 --- /dev/null +++ b/codex-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 53992b927b..58b5566e23 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -70,6 +70,7 @@ use url::Url; pub struct McpConnectionManager { clients: HashMap, server_origins: HashMap, + host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -82,6 +83,7 @@ impl McpConnectionManager { Self { clients: HashMap::new(), server_origins: HashMap::new(), + host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.get().clone(), @@ -116,6 +118,10 @@ impl McpConnectionManager { self.server_origins.get(server_name).map(String::as_str) } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + } + pub fn set_approval_policy(&self, approval_policy: &Constrained) { if let Ok(mut policy) = self.elicitation_requests.approval_policy.lock() { *policy = approval_policy.value(); @@ -148,6 +154,7 @@ impl McpConnectionManager { runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, ) -> (Self, CancellationToken) { @@ -248,6 +255,7 @@ impl McpConnectionManager { let manager = Self { clients, server_origins, + host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d4ee60e89..9b11cbaec1 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -8,11 +8,21 @@ pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; +pub use mcp::host_owned_codex_apps_enabled; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; @@ -39,6 +49,7 @@ pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub(crate) mod auth_elicitation; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index d689ea9043..2fdbb7ccd4 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -198,7 +198,7 @@ pub fn with_codex_apps_mcp( auth: Option<&CodexAuth>, config: &McpConfig, ) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { + if host_owned_codex_apps_enabled(config, auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), codex_apps_mcp_server_config(config), @@ -209,6 +209,10 @@ pub fn with_codex_apps_mcp( servers } +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { config.configured_mcp_servers.clone() } @@ -233,6 +237,7 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -253,6 +258,7 @@ pub async fn read_mcp_resource( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance(config), auth, ) @@ -287,6 +293,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -318,6 +325,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, ) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index c40b7654ab..57d3d276b2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,6 +370,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -3919,6 +3922,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 9f0381f53a..e2154db17e 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -45,6 +45,7 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -246,6 +247,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await; let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -278,6 +280,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, ToolPluginProvenance::default(), auth.as_ref(), ) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 91e2e079ab..a6557b3d67 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -41,8 +41,11 @@ use codex_config::types::AppToolApproval; use codex_features::Feature; use codex_hooks::PermissionRequestDecision; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; use codex_mcp::McpPermissionPromptAutoApproveContext; use codex_mcp::SandboxState; +use codex_mcp::auth_elicitation_completed_result; +use codex_mcp::build_auth_elicitation_plan; use codex_mcp::declared_openai_file_input_param_names; use codex_mcp::mcp_permission_prompt_is_auto_approved; use codex_otel::sanitize_metric_tag_value; @@ -52,6 +55,7 @@ use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; use codex_protocol::mcp::CallToolResult; use codex_protocol::openai_models::InputModality; +use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::ReviewDecision; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -340,9 +344,10 @@ async fn handle_approved_mcp_tool_call( let result = execute_mcp_tool_call( sess, turn_context, - &server, - &tool_name, + call_id, + &invocation, rewritten_arguments, + metadata, request_meta, ) .await; @@ -541,28 +546,145 @@ fn truncate_str_to_char_boundary(value: &str, max_chars: usize) -> &str { async fn execute_mcp_tool_call( sess: &Session, turn_context: &TurnContext, - server: &str, - tool_name: &str, + call_id: &str, + invocation: &McpInvocation, rewritten_arguments: Option, + metadata: Option<&McpToolApprovalMetadata>, request_meta: Option, ) -> Result { let request_meta = with_mcp_tool_call_thread_id_meta(request_meta, &sess.conversation_id.to_string()); - let request_meta = - augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta) - .await - .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; + let request_meta = augment_mcp_tool_request_meta_with_sandbox_state( + sess, + turn_context, + &invocation.server, + request_meta, + ) + .await + .map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?; let result = sess - .call_tool(server, tool_name, rewritten_arguments, request_meta) + .call_tool( + &invocation.server, + &invocation.tool, + rewritten_arguments, + request_meta, + ) .await .map_err(|e| format!("tool call error: {e:?}"))?; - sanitize_mcp_tool_result_for_model( + let result = sanitize_mcp_tool_result_for_model( turn_context .model_info .input_modalities .contains(&InputModality::Image), Ok(result), + )?; + Ok(maybe_request_codex_apps_auth_elicitation( + sess, + turn_context, + call_id, + &invocation.server, + metadata, + result, ) + .await) +} + +async fn maybe_request_codex_apps_auth_elicitation( + sess: &Session, + turn_context: &TurnContext, + call_id: &str, + server: &str, + metadata: Option<&McpToolApprovalMetadata>, + result: CallToolResult, +) -> CallToolResult { + if !sess + .services + .mcp_connection_manager + .read() + .await + .is_host_owned_codex_apps_server(server) + { + return result; + } + + if !turn_context.features.enabled(Feature::AuthElicitation) { + return result; + } + + match turn_context.approval_policy.value() { + AskForApproval::Never => return result, + AskForApproval::Granular(granular_config) if !granular_config.allows_mcp_elicitations() => { + return result; + } + AskForApproval::OnFailure + | AskForApproval::OnRequest + | AskForApproval::UnlessTrusted + | AskForApproval::Granular(_) => {} + } + + let connector_id = metadata.and_then(|metadata| metadata.connector_id.as_deref()); + let connector_name = metadata.and_then(|metadata| metadata.connector_name.as_deref()); + let install_url = connector_id.map(|connector_id| { + codex_connectors::metadata::connector_install_url( + connector_name.unwrap_or(connector_id), + connector_id, + ) + }); + let Some(plan) = + build_auth_elicitation_plan(call_id, &result, connector_id, connector_name, install_url) + else { + return result; + }; + + let request_id = rmcp::model::RequestId::String(plan.elicitation.elicitation_id.clone().into()); + let params = McpServerElicitationRequestParams { + thread_id: sess.conversation_id.to_string(), + turn_id: Some(turn_context.sub_id.clone()), + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: Some(plan.elicitation.meta), + message: plan.elicitation.message, + url: plan.elicitation.url, + elicitation_id: plan.elicitation.elicitation_id, + }, + }; + let response = sess + .request_mcp_server_elicitation(turn_context, request_id, params) + .await; + if !response + .as_ref() + .is_some_and(|response| response.action == ElicitationAction::Accept) + { + return result; + } + + refresh_codex_apps_after_connector_auth(sess, turn_context).await; + auth_elicitation_completed_result(&plan.auth_failure, result.meta) +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "Codex Apps cache refresh reads through the session-owned manager guard" +)] +async fn refresh_codex_apps_after_connector_auth(sess: &Session, turn_context: &TurnContext) { + let mcp_tools_result = { + let manager = sess.services.mcp_connection_manager.read().await; + manager.hard_refresh_codex_apps_tools_cache().await + }; + + match mcp_tools_result { + Ok(mcp_tools) => { + let auth = sess.services.auth_manager.auth().await; + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn_context.config, + auth.as_ref(), + &mcp_tools, + ); + } + Err(err) => { + tracing::warn!("failed to refresh Codex Apps tools after connector auth: {err:#}"); + } + } } #[expect( @@ -834,7 +956,6 @@ pub(crate) struct McpToolApprovalMetadata { openai_file_input_params: Option>, } -const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index a6818579a6..225ee0cf70 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::ConfigBuilder; +use crate::config::ManagedFeatures; use crate::session::tests::make_session_and_context; use crate::session::tests::make_session_and_context_with_rx; use crate::state::ActiveTurn; @@ -14,11 +15,13 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::AppsConfigToml; use codex_config::types::McpServerConfig; use codex_config::types::McpServerToolConfig; +use codex_features::Features; use codex_hooks::Hooks; use codex_hooks::HooksConfig; use codex_model_provider::create_model_provider; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GranularApprovalConfig; use core_test_support::PathExt; use core_test_support::hooks::trusted_config_layer_stack; @@ -1065,6 +1068,250 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod ); } +fn codex_apps_auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + "connector_auth_failure": { + "is_auth_failure": true, + "auth_reason": "reauthentication_required", + "connector_id": "connector_calendar", + "connector_name": "Untrusted Calendar", + "link_id": "link_123", + "error_code": "UNAUTHORIZED", + "error_http_status_code": 401, + "error_action": "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } +} + +fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata { + approval_metadata( + Some("connector_calendar"), + Some("Google Calendar"), + Some("Manage events and schedules."), + Some("Create Event"), + Some("Create a calendar event."), + ) +} + +async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) { + let auth = session.services.auth_manager.auth().await; + let environment = session + .services + .environment_manager + .default_environment() + .unwrap_or_else(|| session.services.environment_manager.local_environment()); + let (manager, _cancel_token) = codex_mcp::McpConnectionManager::new( + &HashMap::new(), + turn_context.config.mcp_oauth_credentials_store_mode, + HashMap::new(), + &turn_context.approval_policy, + turn_context.sub_id.clone(), + session.get_tx_event(), + turn_context.permission_profile(), + codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + turn_context.config.codex_home.to_path_buf(), + codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), + /*host_owned_codex_apps_enabled*/ true, + codex_mcp::ToolPluginProvenance::default(), + auth.as_ref(), + ) + .await; + *session.services.mcp_connection_manager.write().await = manager; +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_disabled_returns_original_result() { + let (session, turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_non_host_owned_server_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_disallowed_by_policy_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Never) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_granular_mcp_disabled_returns_original_result() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + let turn_context = Arc::get_mut(&mut turn_context).expect("single turn context ref"); + turn_context.features = ManagedFeatures::from(features); + turn_context + .approval_policy + .set(AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: true, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: false, + })) + .expect("test setup should allow updating approval policy"); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let returned = maybe_request_codex_apps_auth_elicitation( + &session, + turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result.clone(), + ) + .await; + + assert_eq!(returned, result); + assert!(rx_event.try_recv().is_err()); +} + +#[tokio::test] +async fn codex_apps_auth_elicitation_feature_enabled_requests_elicitation() { + let (session, mut turn_context, rx_event) = make_session_and_context_with_rx().await; + install_host_owned_codex_apps_manager(&session, &turn_context).await; + *session.active_turn.lock().await = Some(ActiveTurn::default()); + let mut features = Features::with_defaults(); + features.enable(Feature::AuthElicitation); + Arc::get_mut(&mut turn_context) + .expect("single turn context ref") + .features = ManagedFeatures::from(features); + let result = codex_apps_auth_failure_result(); + let metadata = codex_apps_auth_failure_metadata(); + + let request_task = tokio::spawn({ + let session = Arc::clone(&session); + let turn_context = Arc::clone(&turn_context); + async move { + maybe_request_codex_apps_auth_elicitation( + &session, + &turn_context, + "call_123", + CODEX_APPS_MCP_SERVER_NAME, + Some(&metadata), + result, + ) + .await + } + }); + + let request = loop { + let event = tokio::time::timeout(std::time::Duration::from_secs(1), rx_event.recv()) + .await + .expect("elicitation event timed out") + .expect("expected elicitation event"); + if let EventMsg::ElicitationRequest(request) = event.msg { + break request; + } + }; + assert_eq!(request.server_name, CODEX_APPS_MCP_SERVER_NAME); + assert_eq!( + request.id, + codex_protocol::mcp::RequestId::String("codex_apps_auth_call_123".to_string()) + ); + assert!(matches!( + request.request, + codex_protocol::approvals::ElicitationRequest::Url { .. } + )); + + session + .resolve_elicitation( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + rmcp::model::RequestId::String("codex_apps_auth_call_123".into()), + ElicitationResponse { + action: ElicitationAction::Accept, + content: None, + meta: None, + }, + ) + .await + .expect("elicitation should resolve"); + let returned = tokio::time::timeout(std::time::Duration::from_secs(1), request_task) + .await + .expect("auth elicitation task timed out") + .expect("auth elicitation task failed"); + assert_eq!( + returned.content, + vec![serde_json::json!({ + "type": "text", + "text": "Authentication for Google Calendar was requested and accepted. Retry this tool call now.", + })] + ); +} + #[test] fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() { assert_eq!( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 89c368d0c2..38ade77ca3 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -233,6 +233,8 @@ impl Session { .tool_plugin_provenance(config.as_ref()) .await; let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = + host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode, auth.as_ref()).await; let mcp_runtime_environment = match turn_context.environments.primary() { @@ -264,6 +266,7 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth.as_ref(), ) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 948678067e..518dacde58 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -307,6 +307,7 @@ use crate::windows_sandbox::WindowsSandboxLevelExt; use codex_core_plugins::PluginsManager; use codex_git_utils::get_git_repo_root; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index d593544b49..92a27b284f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -918,6 +918,9 @@ impl Session { let enabled_mcp_server_count = mcp_servers.values().filter(|server| server.enabled).count(); let required_mcp_server_count = required_mcp_servers.len(); let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config.as_ref()).await; + let host_owned_codex_apps_enabled = config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend())); { let mut cancel_guard = sess.services.mcp_startup_cancellation_token.lock().await; cancel_guard.cancel(); @@ -959,6 +962,7 @@ impl Session { mcp_runtime_environment, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, tool_plugin_provenance, auth, ) diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 5b83d3b136..df042029ae 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -74,6 +74,7 @@ fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { "mcpServers": {{ "sample": {{ "command": "{command}", + "cwd": ".", "startup_timeout_sec": 60.0 }} }} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index acb6fa195e..dfacc0d55b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -204,6 +204,8 @@ pub enum Feature { CollaborationModes, /// Route MCP tool approval prompts through the MCP elicitation request path. ToolCallMcpElicitation, + /// Prompt Codex Apps connector auth failures through MCP URL elicitations. + AuthElicitation, /// Enable personality selection in the TUI. Personality, /// Enable native artifact tools. @@ -1045,6 +1047,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::AuthElicitation, + key: "auth_elicitation", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Personality, key: "personality", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index da0e36442c..4a258141b5 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -247,6 +247,16 @@ fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); } +#[test] +fn auth_elicitation_is_under_development() { + assert_eq!(Feature::AuthElicitation.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::AuthElicitation.default_enabled(), false); + assert_eq!( + feature_for_key("auth_elicitation"), + Some(Feature::AuthElicitation) + ); +} + #[test] fn remote_control_is_under_development() { assert_eq!(Feature::RemoteControl.stage(), Stage::UnderDevelopment); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0ecbd02d61..5e90bb863b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::AppServerStartedThread; use crate::app_server_session::app_server_rate_limit_snapshots; +use crate::bottom_pane::AppLinkViewParams; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::FeedbackAudience; use crate::bottom_pane::McpServerElicitationFormRequest; @@ -219,6 +220,7 @@ const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue." const THREAD_EVENT_CHANNEL_CAPACITY: usize = 32768; enum ThreadInteractiveRequest { + AppLink(AppLinkViewParams), Approval(ApprovalRequest), McpServerElicitation(McpServerElicitationFormRequest), } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bb00b53533..3f061fa516 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -37,6 +37,8 @@ use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::FileUpdateChange; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpServerElicitationRequest; +use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::NetworkApprovalContext as AppServerNetworkApprovalContext; @@ -2755,6 +2757,84 @@ async fn inactive_thread_permissions_approval_preserves_file_system_permissions( ); } +#[tokio::test] +async fn inactive_thread_url_elicitation_routes_to_app_link() { + let app = make_test_app().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(9), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + let Some(ThreadInteractiveRequest::AppLink(params)) = app + .interactive_request_for_thread_request(thread_id, &request) + .await + else { + panic!("expected app link request"); + }; + + assert_eq!(params.title, "Action required"); + assert_eq!(params.description, Some("Server: payments".to_string())); + assert_eq!(params.url, "https://payments.example/checkout/123"); + assert_eq!( + params.elicitation_target, + Some(crate::bottom_pane::AppLinkElicitationTarget { + thread_id, + server_name: "payments".to_string(), + request_id: AppServerRequestId::Integer(9), + }) + ); +} + +#[tokio::test] +async fn inactive_thread_invalid_url_elicitation_is_declined() { + let (app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let request = ServerRequest::McpServerElicitationRequest { + request_id: AppServerRequestId::Integer(10), + params: McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + }; + + assert!( + app.interactive_request_for_thread_request(thread_id, &request) + .await + .is_none() + ); + assert_matches!( + app_event_rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: AppServerRequestId::Integer(10), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn inactive_thread_approval_badge_clears_after_turn_completion_notification() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index df6f01e8bd..1915cc7683 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -262,31 +262,47 @@ impl App { }), ), ServerRequest::McpServerElicitationRequest { request_id, params } => { - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = AppLinkViewParams::from_url_app_server_request( thread_id, + ¶ms.server_name, request_id.clone(), - params.clone(), + ¶ms.request, ) { + Some(ThreadInteractiveRequest::AppLink(params)) + } else if let Some(request) = + McpServerElicitationFormRequest::from_app_server_request( + thread_id, + request_id.clone(), + params.clone(), + ) + { Some(ThreadInteractiveRequest::McpServerElicitation(request)) } else { - Some(ThreadInteractiveRequest::Approval( - ApprovalRequest::McpElicitation { - thread_id, - thread_label, - server_name: params.server_name.clone(), - request_id: request_id.clone(), - message: match ¶ms.request { - codex_app_server_protocol::McpServerElicitationRequest::Form { - message, - .. - } - | codex_app_server_protocol::McpServerElicitationRequest::Url { - message, - .. - } => message.clone(), + match ¶ms.request { + codex_app_server_protocol::McpServerElicitationRequest::Form { + message, + .. + } => Some(ThreadInteractiveRequest::Approval( + ApprovalRequest::McpElicitation { + thread_id, + thread_label, + server_name: params.server_name.clone(), + request_id: request_id.clone(), + message: message.clone(), }, - }, - )) + )), + codex_app_server_protocol::McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name.clone(), + request_id.clone(), + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + None + } + } } } ServerRequest::PermissionsRequestApproval { params, .. } => Some( @@ -304,6 +320,9 @@ impl App { pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) { match request { + ThreadInteractiveRequest::AppLink(params) => { + self.chat_widget.open_app_link_view(params); + } ThreadInteractiveRequest::Approval(request) => { self.render_inactive_patch_preview(&request); self.chat_widget.push_approval_request(request); diff --git a/codex-rs/tui/src/bottom_pane/app_link_view.rs b/codex-rs/tui/src/bottom_pane/app_link_view.rs index 43ff94618d..3702849fce 100644 --- a/codex-rs/tui/src/bottom_pane/app_link_view.rs +++ b/codex-rs/tui/src/bottom_pane/app_link_view.rs @@ -17,6 +17,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use textwrap::wrap; +use url::Url; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -34,6 +35,13 @@ use crate::style::user_message_style; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_lines; +const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps"; +const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum AppLinkScreen { Link, @@ -44,6 +52,8 @@ enum AppLinkScreen { pub(crate) enum AppLinkSuggestionType { Install, Enable, + Auth, + ExternalAction, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,6 +63,7 @@ pub(crate) struct AppLinkElicitationTarget { pub(crate) request_id: AppServerRequestId, } +#[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct AppLinkViewParams { pub(crate) app_id: String, pub(crate) title: String, @@ -66,6 +77,152 @@ pub(crate) struct AppLinkViewParams { pub(crate) elicitation_target: Option, } +impl AppLinkViewParams { + pub(crate) fn from_url_app_server_request( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + request: &codex_app_server_protocol::McpServerElicitationRequest, + ) -> Option { + let codex_app_server_protocol::McpServerElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } = request + else { + return None; + }; + if server_name == MCP_CODEX_APPS_SERVER_NAME { + let url = validate_external_url(url, /*require_chatgpt_host*/ true)?; + return Self::from_codex_apps_auth_url_parts( + thread_id, + server_name, + request_id, + meta.as_ref(), + message, + url.as_str(), + elicitation_id, + ); + } + + let url = validate_external_url(url, /*require_chatgpt_host*/ false)?; + Some(Self::from_generic_url_parts( + thread_id, + server_name, + request_id, + message, + url.as_str(), + elicitation_id, + )) + } + + fn from_codex_apps_auth_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + meta: Option<&serde_json::Value>, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Option { + let auth_failure = meta? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let app_id = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(elicitation_id) + .to_string(); + let title = auth_failure + .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(app_id.as_str()) + .to_string(); + + Some(Self { + app_id, + title, + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + }) + } + + fn from_generic_url_parts( + thread_id: ThreadId, + server_name: &str, + request_id: AppServerRequestId, + message: &str, + url: &str, + elicitation_id: &str, + ) -> Self { + Self { + app_id: elicitation_id.to_string(), + title: "Action required".to_string(), + description: Some(format!("Server: {server_name}")), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: url.to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some(message.to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(AppLinkElicitationTarget { + thread_id, + server_name: server_name.to_string(), + request_id, + }), + } + } +} + +fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option { + let parsed = Url::parse(url).ok()?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return None; + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return None; + } + if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) { + return None; + } + Some(parsed) +} + +fn is_allowed_chatgpt_auth_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == "chatgpt.com" + || host == "chatgpt-staging.com" + || host.ends_with(".chatgpt.com") + || host.ends_with(".chatgpt-staging.com") +} + pub(crate) struct AppLinkView { app_id: String, title: String, @@ -116,6 +273,19 @@ impl AppLinkView { } fn action_labels(&self) -> Vec<&'static str> { + if self.is_auth_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open sign-in URL", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"], + }; + } + if self.is_external_action_suggestion() { + return match self.screen { + AppLinkScreen::Link => vec!["Open link", "Back"], + AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"], + }; + } + match self.screen { AppLinkScreen::Link => { if self.is_installed { @@ -148,6 +318,19 @@ impl AppLinkView { self.elicitation_target.is_some() } + fn is_auth_suggestion(&self) -> bool { + self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth) + } + + fn is_external_action_suggestion(&self) -> bool { + self.is_tool_suggestion() + && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction) + } + + fn is_browser_action_suggestion(&self) -> bool { + self.is_auth_suggestion() || self.is_external_action_suggestion() + } + fn resolve_elicitation(&self, decision: McpServerElicitationAction) { let Some(target) = self.elicitation_target.as_ref() else { return; @@ -167,20 +350,26 @@ impl AppLinkView { self.complete = true; } - fn open_chatgpt_link(&mut self) { + fn open_external_url(&mut self) { self.app_event_tx.send(AppEvent::OpenUrlInBrowser { url: self.url.clone(), }); - if !self.is_installed { + if !self.is_installed || self.is_browser_action_suggestion() { self.screen = AppLinkScreen::InstallConfirmation; self.selected_action = 0; } } - fn refresh_connectors_and_close(&mut self) { - self.app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); + fn complete_external_flow_and_close(&mut self) { + let should_refresh_connectors = self + .elicitation_target + .as_ref() + .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + if should_refresh_connectors { + self.app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } if self.is_tool_suggestion() { self.resolve_elicitation(McpServerElicitationAction::Accept); } @@ -209,22 +398,42 @@ impl AppLinkView { match self.suggestion_type { Some(AppLinkSuggestionType::Enable) => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::Auth) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), + _ => self.decline_tool_suggestion(), + }, + }, + Some(AppLinkSuggestionType::ExternalAction) => match self.screen { + AppLinkScreen::Link => match self.selected_action { + 0 => self.open_external_url(), + _ => self.decline_tool_suggestion(), + }, + AppLinkScreen::InstallConfirmation => match self.selected_action { + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, Some(AppLinkSuggestionType::Install) | None => match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), _ => self.decline_tool_suggestion(), }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.decline_tool_suggestion(), }, }, @@ -234,12 +443,12 @@ impl AppLinkView { match self.screen { AppLinkScreen::Link => match self.selected_action { - 0 => self.open_chatgpt_link(), + 0 => self.open_external_url(), 1 if self.is_installed => self.toggle_enabled(), _ => self.complete = true, }, AppLinkScreen::InstallConfirmation => match self.selected_action { - 0 => self.refresh_connectors_and_close(), + 0 => self.complete_external_flow_and_close(), _ => self.back_to_link_screen(), }, } @@ -280,31 +489,42 @@ impl AppLinkView { } lines.push(Line::from("")); } - if self.is_installed { + let is_browser_action_suggestion = self.is_browser_action_suggestion(); + if self.is_installed && !is_browser_action_suggestion { for line in wrap("Use $ to insert this app into the prompt.", usable_width) { lines.push(Line::from(line.into_owned())); } lines.push(Line::from("")); } + if is_browser_action_suggestion { + lines.push(Line::from("URL".dim())); + for line in wrap(&self.url, usable_width) { + lines.push(Line::from(line.into_owned())); + } + lines.push(Line::from("")); + } + let instructions = self.instructions.trim(); if !instructions.is_empty() { for line in wrap(instructions, usable_width) { lines.push(Line::from(line.into_owned())); } - for line in wrap( - "Newly installed apps can take a few minutes to appear in /apps.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - if !self.is_installed { + if !is_browser_action_suggestion { for line in wrap( - "After installed, use $ to insert this app into the prompt.", + "Newly installed apps can take a few minutes to appear in /apps.", usable_width, ) { lines.push(Line::from(line.into_owned())); } + if !self.is_installed { + for line in wrap( + "After installed, use $ to insert this app into the prompt.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } } lines.push(Line::from("")); } @@ -316,24 +536,82 @@ impl AppLinkView { let usable_width = width.max(1) as usize; let mut lines: Vec> = Vec::new(); - lines.push(Line::from("Finish App Setup".bold())); + let is_auth_suggestion = self.is_auth_suggestion(); + let is_external_action_suggestion = self.is_external_action_suggestion(); + let is_codex_apps_auth = is_auth_suggestion + && self + .elicitation_target + .as_ref() + .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME); + lines.push(Line::from( + if is_auth_suggestion { + if is_codex_apps_auth { + "Finish App Sign In" + } else { + "Finish Authentication" + } + } else if is_external_action_suggestion { + "Finish in Browser" + } else { + "Finish App Setup" + } + .bold(), + )); lines.push(Line::from("")); - for line in wrap( - "Complete app setup on ChatGPT in the browser window that just opened.", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); - } - for line in wrap( - "Sign in there if needed, then return here and select \"I already Installed it\".", - usable_width, - ) { - lines.push(Line::from(line.into_owned())); + if is_auth_suggestion { + for line in wrap( + if is_codex_apps_auth { + "Sign in to the app on ChatGPT in the browser window that just opened." + } else { + "Complete authentication in the browser window that just opened." + }, + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Then return here and select \"I already signed in\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + } else if is_external_action_suggestion { + for line in wrap( + "Complete the requested action in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap("Then return here and select \"I finished\".", usable_width) { + lines.push(Line::from(line.into_owned())); + } + } else { + for line in wrap( + "Complete app setup on ChatGPT in the browser window that just opened.", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } + for line in wrap( + "Sign in there if needed, then return here and select \"I already Installed it\".", + usable_width, + ) { + lines.push(Line::from(line.into_owned())); + } } lines.push(Line::from("")); - lines.push(Line::from(vec!["Setup URL:".dim()])); + lines.push(Line::from(vec![ + if is_auth_suggestion { + "Sign-in URL:" + } else if is_external_action_suggestion { + "Link:" + } else { + "Setup URL:" + } + .dim(), + ])); let url_line = Line::from(vec![self.url.clone().cyan().underlined()]); lines.extend(adaptive_wrap_lines( vec![url_line], @@ -586,6 +864,135 @@ mod tests { } } + fn generic_url_target() -> AppLinkElicitationTarget { + AppLinkElicitationTarget { + thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") + .expect("valid thread id"), + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + } + } + + fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest { + codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: Some(serde_json::json!({ + "_codex_apps": { + "connector_auth_failure": { + "is_auth_failure": true, + "connector_id": "connector_calendar", + "connector_name": "Google Calendar", + }, + }, + })), + message: "Reconnect Google Calendar on ChatGPT.".to_string(), + url: url.to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + } + + #[test] + fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() { + let target = suggestion_target(); + let request = + auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar"); + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected auth app link params"); + + assert_eq!(params.app_id, "connector_calendar"); + assert_eq!(params.title, "Google Calendar"); + assert_eq!( + params.url, + "https://chatgpt.com/apps/google-calendar/connector_calendar" + ); + assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth)); + assert_eq!(params.elicitation_target, Some(target)); + } + + #[test] + fn non_codex_apps_url_elicitation_builds_generic_app_link_params() { + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + + assert_eq!( + params, + AppLinkViewParams { + app_id: "payment-123".to_string(), + title: "Action required".to_string(), + description: Some("Server: payments".to_string()), + instructions: "Complete the requested action in your browser, then return here." + .to_string(), + url: "https://payments.example/checkout/123".to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Review the payment details to continue.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::ExternalAction), + elicitation_target: Some(target), + } + ); + } + + #[test] + fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() { + let target = suggestion_target(); + for url in [ + "http://chatgpt.com/apps/google-calendar/connector_calendar", + "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar", + "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar", + "https://evilchatgpt.com/apps/google-calendar/connector_calendar", + ] { + let request = auth_url_request(url); + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + + #[test] + fn generic_url_elicitation_rejects_untrusted_urls() { + let target = generic_url_target(); + for url in [ + "http://payments.example/checkout/123", + "https://user:pass@payments.example/checkout/123", + ] { + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: url.to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ); + assert!(params.is_none(), "expected {url} to be rejected"); + } + } + fn render_snapshot(view: &AppLinkView, area: Rect) -> String { let mut buf = Buffer::empty(area); view.render(area, &mut buf); @@ -717,6 +1124,58 @@ mod tests { ); } + #[test] + fn generic_url_elicitation_resolves_without_connector_refresh() { + let (tx_raw, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::OpenUrlInBrowser { url }) => { + assert_eq!(url, "https://payments.example/checkout/123"); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert_eq!(view.screen, AppLinkScreen::InstallConfirmation); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + match rx.try_recv() { + Ok(AppEvent::SubmitThreadOp { thread_id, op }) => { + assert_eq!(thread_id, target.thread_id); + assert_eq!( + op, + Op::ResolveElicitation { + server_name: "payments".to_string(), + request_id: AppServerRequestId::String("request-2".to_string()), + decision: McpServerElicitationAction::Accept, + content: None, + meta: None, + } + ); + } + Ok(other) => panic!("unexpected app event: {other:?}"), + Err(err) => panic!("missing app event: {err}"), + } + assert!(rx.try_recv().is_err()); + assert!(view.is_complete()); + } + #[test] fn install_confirmation_does_not_split_long_url_like_token_without_scheme() { let (tx_raw, _rx) = unbounded_channel::(); @@ -1076,4 +1535,94 @@ mod tests { ) ); } + + #[test] + fn auth_suggestion_with_reason_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let view = AppLinkView::new( + AppLinkViewParams { + app_id: "connector_google_calendar".to_string(), + title: "Google Calendar".to_string(), + description: None, + instructions: "Sign in to this app in your browser, then return here.".to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar" + .to_string(), + is_installed: true, + is_enabled: true, + suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()), + suggestion_type: Some(AppLinkSuggestionType::Auth), + elicitation_target: Some(suggestion_target()), + }, + tx, + ); + + assert_snapshot!( + "app_link_view_auth_suggestion_with_reason", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let view = AppLinkView::new(params, tx); + + assert_snapshot!( + "app_link_view_generic_url_elicitation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } + + #[test] + fn generic_url_elicitation_confirmation_snapshot() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let target = generic_url_target(); + let request = codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "https://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }; + let params = AppLinkViewParams::from_url_app_server_request( + target.thread_id, + &target.server_name, + target.request_id.clone(), + &request, + ) + .expect("expected generic URL app link params"); + let mut view = AppLinkView::new(params, tx); + + view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_snapshot!( + "app_link_view_generic_url_elicitation_confirmation", + render_snapshot( + &view, + Rect::new(0, 0, 72, view.desired_height(/*width*/ 72)) + ) + ); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 7b0694e0b3..29dbf59427 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1315,6 +1315,12 @@ impl BottomPane { AppLinkSuggestionType::Enable => { "Enable this app to use it for the current request.".to_string() } + AppLinkSuggestionType::Auth => unreachable!( + "auth uses URL mode elicitation, not tool suggestion forms" + ), + AppLinkSuggestionType::ExternalAction => unreachable!( + "external actions use URL mode elicitation, not tool suggestion forms" + ), }, url: install_url, is_installed, diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap new file mode 100644 index 0000000000..0cf228c129 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_auth_suggestion_with_reason.snap @@ -0,0 +1,18 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Google Calendar + + Reconnect Google Calendar on ChatGPT. + + URL + https://chatgpt.com/apps/google-calendar/connector_google_calendar + + Sign in to this app in your browser, then return here. + + + › 1. Open sign-in URL + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap new file mode 100644 index 0000000000..ec95ab96fa --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Action required + Server: payments + + Review the payment details to continue. + + URL + https://payments.example/checkout/123 + + Complete the requested action in your browser, then return here. + + + › 1. Open link + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap new file mode 100644 index 0000000000..14236e2e76 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__app_link_view__tests__app_link_view_generic_url_elicitation_confirmation.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/app_link_view.rs +expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))" +--- + + Finish in Browser + + Complete the requested action in the browser window that just + opened. + Then return here and select "I finished". + + Link: + https://payments.example/checkout/123 + + › 1. I finished + 2. Back + Use tab / ↑ ↓ to move, enter to select, esc to close diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0b58a913cb..377ebf3a9e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4557,7 +4557,14 @@ impl ChatWidget { }); let thread_id = self.thread_id.unwrap_or_default(); - if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( + if let Some(params) = crate::bottom_pane::AppLinkViewParams::from_url_app_server_request( + thread_id, + ¶ms.server_name, + request_id.clone(), + ¶ms.request, + ) { + self.open_app_link_view(params); + } else if let Some(request) = McpServerElicitationFormRequest::from_app_server_request( thread_id, request_id.clone(), params.clone(), @@ -4565,18 +4572,29 @@ impl ChatWidget { self.bottom_pane .push_mcp_server_elicitation_request(request); } else { - let request = ApprovalRequest::McpElicitation { - thread_id, - thread_label: None, - server_name: params.server_name, - request_id, - message: match params.request { - McpServerElicitationRequest::Form { message, .. } - | McpServerElicitationRequest::Url { message, .. } => message, - }, - }; - self.bottom_pane - .push_approval_request(request, &self.config.features); + match params.request { + McpServerElicitationRequest::Form { message, .. } => { + let request = ApprovalRequest::McpElicitation { + thread_id, + thread_label: None, + server_name: params.server_name, + request_id, + message, + }; + self.bottom_pane + .push_approval_request(request, &self.config.features); + } + McpServerElicitationRequest::Url { .. } => { + self.app_event_tx.resolve_elicitation( + thread_id, + params.server_name, + request_id, + codex_app_server_protocol::McpServerElicitationAction::Decline, + /*content*/ None, + /*meta*/ None, + ); + } + } } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 3e8a0f631e..059366791e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,42 @@ use super::*; use pretty_assertions::assert_eq; +#[tokio::test] +async fn invalid_url_elicitation_is_declined() { + let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + + chat.handle_elicitation_request_now( + codex_app_server_protocol::RequestId::Integer(9), + codex_app_server_protocol::McpServerElicitationRequestParams { + thread_id: thread_id.to_string(), + turn_id: Some("turn-auth".to_string()), + server_name: "payments".to_string(), + request: codex_app_server_protocol::McpServerElicitationRequest::Url { + meta: None, + message: "Review the payment details to continue.".to_string(), + url: "http://payments.example/checkout/123".to_string(), + elicitation_id: "payment-123".to_string(), + }, + }, + ); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::SubmitThreadOp { + thread_id: op_thread_id, + op: Op::ResolveElicitation { + server_name, + request_id: codex_app_server_protocol::RequestId::Integer(9), + decision: codex_app_server_protocol::McpServerElicitationAction::Decline, + content: None, + meta: None, + }, + }) if op_thread_id == thread_id && server_name == "payments" + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; From a98623511ba433154ec811fc63091617f5945438 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 10:48:37 +0200 Subject: [PATCH 08/30] feat: add `session_id` (#20437) ## Summary Related to https://openai.slack.com/archives/C095U48JNL9/p1777537279707449 TLDR: We update the meaning of session ids and thread ids: * thread_id stays as now * session_id become a shared id between every thread under a /root thread (i.e. every sub-agent share the same session id) This PR introduces an explicit `SessionId` and threads it through the protocol/client boundary so `session_id` and `thread_id` can diverge when they need to, while preserving compatibility for older serialized `session_configured` events. --------- Co-authored-by: Codex --- .../analytics/src/analytics_client_tests.rs | 2 + codex-rs/analytics/src/client_tests.rs | 2 + .../codex_app_server_protocol.schemas.json | 10 + .../codex_app_server_protocol.v2.schemas.json | 10 + .../schema/json/v2/ThreadResumeResponse.json | 5 + .../schema/json/v2/ThreadStartResponse.json | 5 + .../typescript/v2/ThreadResumeResponse.ts | 5 +- .../typescript/v2/ThreadStartResponse.ts | 5 +- .../src/protocol/common.rs | 2 + .../src/protocol/v2/thread.rs | 6 + .../request_processors/thread_lifecycle.rs | 2 + .../request_processors/thread_processor.rs | 2 + .../tests/suite/v2/thread_resume.rs | 5 +- .../app-server/tests/suite/v2/thread_start.rs | 2 + codex-rs/codex-api/src/endpoint/responses.rs | 14 +- codex-rs/codex-api/src/lib.rs | 2 +- codex-rs/codex-api/src/requests/headers.rs | 7 +- codex-rs/codex-api/tests/clients.rs | 13 +- codex-rs/core/src/agent/control.rs | 13 ++ codex-rs/core/src/client.rs | 40 ++-- codex-rs/core/src/client_tests.rs | 8 +- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/session/session.rs | 31 ++- codex-rs/core/src/session/tests.rs | 203 ++++++++++++++++-- codex-rs/core/tests/responses_headers.rs | 23 +- codex-rs/core/tests/suite/approvals.rs | 2 +- codex-rs/core/tests/suite/client.rs | 37 ++-- .../core/tests/suite/client_websockets.rs | 25 ++- codex-rs/core/tests/suite/compact_remote.rs | 10 + codex-rs/core/tests/suite/fork_thread.rs | 2 +- codex-rs/core/tests/suite/items.rs | 10 +- codex-rs/core/tests/suite/model_switching.rs | 6 +- codex-rs/core/tests/suite/search_tool.rs | 2 +- codex-rs/core/tests/suite/sqlite_state.rs | 10 +- .../tests/suite/subagent_notifications.rs | 2 +- .../src/event_processor_with_jsonl_output.rs | 2 +- codex-rs/exec/src/lib.rs | 16 +- codex-rs/exec/src/lib_tests.rs | 9 + .../tests/event_processor_with_json_output.rs | 7 +- codex-rs/mcp-server/src/outgoing_message.rs | 13 +- codex-rs/memories/write/src/runtime.rs | 2 + codex-rs/memories/write/src/startup_tests.rs | 4 +- codex-rs/protocol/src/lib.rs | 2 + codex-rs/protocol/src/protocol.rs | 18 +- codex-rs/protocol/src/session_id.rs | 126 +++++++++++ codex-rs/protocol/src/thread_id.rs | 2 +- codex-rs/tui/src/app_server_session.rs | 1 + .../chatwidget/tests/composer_submission.rs | 36 ++-- .../tui/src/chatwidget/tests/exec_flow.rs | 4 +- .../src/chatwidget/tests/history_replay.rs | 20 +- .../tui/src/chatwidget/tests/plan_mode.rs | 4 +- 51 files changed, 638 insertions(+), 153 deletions(-) create mode 100644 codex-rs/protocol/src/session_id.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 823dcace91..7194e32443 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -154,6 +154,7 @@ fn sample_thread_start_response( model: &str, ) -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { + session_id: format!("session-{thread_id}"), thread: sample_thread_with_metadata( thread_id, ephemeral, @@ -215,6 +216,7 @@ fn sample_thread_resume_response_with_source( thread_source: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { + session_id: format!("session-{thread_id}"), thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), model: model.to_string(), model_provider: "openai".to_string(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index c36b5cf9a7..42d508602a 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -102,6 +102,7 @@ fn sample_permission_profile() -> AppServerPermissionProfile { fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { + session_id: "session-1".to_string(), thread: sample_thread("thread-1"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -119,6 +120,7 @@ fn sample_thread_start_response() -> ClientResponsePayload { fn sample_thread_resume_response() -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { + session_id: "session-2".to_string(), thread: sample_thread("thread-2"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), 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 881380bf19..d6f184a6db 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 @@ -17171,6 +17171,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -17494,6 +17499,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/v2/Thread" } 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 109294746d..1f252cbd23 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 @@ -15057,6 +15057,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } @@ -15380,6 +15385,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 81eff83400..b00fd513e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -2619,6 +2619,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index b2d5cbfa08..7451d08977 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -2619,6 +2619,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f7627c07ae..cc4c2440f7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -9,7 +9,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index ce28a4a1d7..962ed2437e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -9,7 +9,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae687a21b2..8fd267d014 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2176,6 +2176,7 @@ mod tests { let response = ClientResponse::ThreadStart { request_id: RequestId::Integer(7), response: v2::ThreadStartResponse { + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), forked_from_id: None, @@ -2217,6 +2218,7 @@ mod tests { "method": "thread/start", "id": 7, "response": { + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "forkedFromId": null, 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 578ef9193f..d8250987a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -189,6 +189,9 @@ pub struct MockExperimentalMethodResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadStartResponse { + /// Session id shared by threads that belong to the same session tree. + #[serde(default)] + pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, @@ -304,6 +307,9 @@ pub struct ThreadResumeParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadResumeResponse { + /// Session id shared by threads that belong to the same session tree. + #[serde(default)] + pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 4a677d91ab..bd93893b79 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -598,8 +598,10 @@ pub(super) async fn handle_pending_thread_resume_request( let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); + let session_id = conversation.session_configured().session_id.to_string(); let response = ThreadResumeResponse { + session_id, thread, model, model_provider: model_provider_id, 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 147c4cd721..5563bd56e9 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1148,6 +1148,7 @@ impl ThreadRequestProcessor { thread_response_active_permission_profile(config_snapshot.active_permission_profile); let response = ThreadStartResponse { + session_id: session_configured.session_id.to_string(), thread: thread.clone(), model: config_snapshot.model, model_provider: config_snapshot.model_provider_id, @@ -2476,6 +2477,7 @@ impl ThreadRequestProcessor { ); let response = ThreadResumeResponse { + session_id: session_configured.session_id.to_string(), thread, model: session_configured.model, model_provider: session_configured.model_provider_id, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 4014e6f975..175da83016 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -270,7 +270,10 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + let ThreadResumeResponse { + session_id, thread, .. + } = to_response::(resume_resp)?; + assert!(!session_id.is_empty(), "session id should not be empty"); assert_eq!(thread.thread_source, Some(ThreadSource::User)); let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 68fc818ec2..e300e562d9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -121,10 +121,12 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .await??; let resp_result = resp.result.clone(); let ThreadStartResponse { + session_id, thread, model_provider, .. } = to_response::(resp)?; + assert!(!session_id.is_empty(), "session id should not be empty"); assert!(!thread.id.is_empty(), "thread id should not be empty"); assert!( thread.preview.is_empty(), diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 17b478d1fd..cc1be2846a 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -6,7 +6,7 @@ use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; use crate::requests::attach_item_ids; -use crate::requests::headers::build_conversation_headers; +use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; use crate::sse::spawn_response_stream; @@ -30,7 +30,8 @@ pub struct ResponsesClient { #[derive(Default)] pub struct ResponsesOptions { - pub conversation_id: Option, + pub session_id: Option, + pub thread_id: Option, pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, @@ -72,7 +73,8 @@ impl ResponsesClient { options: ResponsesOptions, ) -> Result { let ResponsesOptions { - conversation_id, + session_id, + thread_id, session_source, extra_headers, compression, @@ -86,10 +88,10 @@ impl ResponsesClient { } let mut headers = extra_headers; - if let Some(ref conv_id) = conversation_id { - insert_header(&mut headers, "x-client-request-id", conv_id); + if let Some(ref thread_id) = thread_id { + insert_header(&mut headers, "x-client-request-id", thread_id); } - headers.extend(build_conversation_headers(conversation_id)); + headers.extend(build_session_headers(session_id, thread_id)); if let Some(subagent) = subagent_header(&session_source) { insert_header(&mut headers, "x-openai-subagent", &subagent); } diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 0b8aee266b..6913c6e8ca 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -10,7 +10,7 @@ pub(crate) mod requests; pub(crate) mod sse; pub(crate) mod telemetry; -pub use crate::requests::headers::build_conversation_headers; +pub use crate::requests::headers::build_session_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index d1ab834109..d91d2a2bf1 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -2,11 +2,14 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; -pub fn build_conversation_headers(conversation_id: Option) -> HeaderMap { +pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Some(id) = conversation_id { + if let Some(id) = session_id { insert_header(&mut headers, "session_id", &id); } + if let Some(id) = thread_id { + insert_header(&mut headers, "thread_id", &id); + } headers } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 218a99f9b2..a2a29ba16d 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -444,7 +444,8 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { .stream_request( request, ResponsesOptions { - conversation_id: Some("sess_123".into()), + session_id: Some("sess_123".into()), + thread_id: Some("thread_123".into()), session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), extra_headers, compression: Compression::None, @@ -461,6 +462,16 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { req.headers.get("session_id").and_then(|v| v.to_str().ok()), Some("sess_123") ); + assert_eq!( + req.headers.get("thread_id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-client-request-id") + .and_then(|v| v.to_str().ok()), + Some("thread_123") + ); assert_eq!( req.headers .get("x-openai-subagent") diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index e5967a6ed2..8c8114324c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -14,6 +14,7 @@ use crate::thread_manager::ThreadManagerState; use crate::thread_rollout_truncation::truncate_rollout_to_last_n_fork_turns; use codex_features::Feature; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -132,6 +133,9 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { /// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { + /// ID shared by the whole agent control session. This means every sub-agents from a common + /// root share the same session ID. + session_id: SessionId, /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. @@ -148,6 +152,15 @@ impl AgentControl { } } + pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + self.session_id = session_id; + self + } + + pub(crate) fn session_id(&self) -> SessionId { + self.session_id + } + /// Spawn a new agent thread and submit the initial prompt. #[cfg(test)] pub(crate) async fn spawn_agent( diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d11fda47dc..00feb81a9f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -58,7 +58,7 @@ use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; -use codex_api::build_conversation_headers; +use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; use codex_app_server_protocol::AuthMode; @@ -70,6 +70,7 @@ use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::ServiceTier; @@ -152,7 +153,8 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = /// configuration is per turn and is passed explicitly to streaming/unary methods. #[derive(Debug)] struct ModelClientState { - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, @@ -190,7 +192,7 @@ impl RequestRouteTelemetry { /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session -/// (auth, provider selection, conversation id, and transport fallback state). +/// (auth, provider selection, thread id, and transport fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. @@ -297,7 +299,8 @@ impl ModelClient { /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. pub fn new( auth_manager: Option>, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, @@ -315,7 +318,8 @@ impl ModelClient { collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled); Self { state: Arc::new(ModelClientState { - conversation_id, + session_id, + thread_id, window_generation: AtomicU64::new(0), installation_id, provider: model_provider, @@ -360,9 +364,9 @@ impl ModelClient { } fn current_window_id(&self) -> String { - let conversation_id = self.state.conversation_id; + let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); - format!("{conversation_id}:{window_generation}") + format!("{thread_id}:{window_generation}") } fn take_cached_websocket_session(&self) -> WebsocketSession { @@ -475,9 +479,10 @@ impl ModelClient { /*turn_metadata_header*/ None, )); extra_headers.extend(self.build_responses_identity_headers()); - extra_headers.extend(build_conversation_headers(Some( - self.state.conversation_id.to_string(), - ))); + extra_headers.extend(build_session_headers( + Some(self.state.session_id.to_string()), + Some(self.state.thread_id.to_string()), + )); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client .compact_input(&payload, extra_headers) @@ -696,7 +701,7 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.conversation_id.to_string()); + let prompt_cache_key = Some(self.state.thread_id.to_string()); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), @@ -850,16 +855,17 @@ impl ModelClient { turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.state.conversation_id.to_string(); + let session_id = self.state.session_id.to_string(); + let thread_id = self.state.thread_id.to_string(); let mut headers = build_responses_headers( self.state.beta_features_header.as_deref(), turn_state, turn_metadata_header.as_ref(), ); - if let Ok(header_value) = HeaderValue::from_str(&conversation_id) { + if let Ok(header_value) = HeaderValue::from_str(&thread_id) { headers.insert("x-client-request-id", header_value); } - headers.extend(build_conversation_headers(Some(conversation_id))); + headers.extend(build_session_headers(Some(session_id), Some(thread_id))); headers.extend(self.build_responses_identity_headers()); headers.insert( OPENAI_BETA_HEADER, @@ -903,9 +909,11 @@ impl ModelClientSession { compression: Compression, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.client.state.conversation_id.to_string(); + let session_id = self.client.state.session_id.to_string(); + let thread_id = self.client.state.thread_id.to_string(); ApiResponsesOptions { - conversation_id: Some(conversation_id), + session_id: Some(session_id), + thread_id: Some(thread_id), session_source: Some(self.client.state.session_source.clone()), extra_headers: { let mut headers = build_responses_headers( diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 13c5603baa..2ba65d7c45 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -52,9 +52,11 @@ use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); + let thread_id = ThreadId::new(); ModelClient::new( /*auth_manager*/ None, - ThreadId::new(), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, @@ -270,7 +272,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); - let conversation_id = client.state.conversation_id; + let thread_id = client.state.thread_id; assert_eq!( client_metadata, std::collections::HashMap::from([ @@ -280,7 +282,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { ), ( X_CODEX_WINDOW_ID_HEADER.to_string(), - format!("{conversation_id}:1"), + format!("{thread_id}:1"), ), ( X_OPENAI_SUBAGENT_HEADER.to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 2f74bd48b4..508e8c2fac 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -391,7 +391,7 @@ impl CodexThread { self.rollout_path.clone() } - pub(crate) fn session_configured(&self) -> SessionConfiguredEvent { + pub fn session_configured(&self) -> SessionConfiguredEvent { self.session_configured.clone() } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 92a27b284f..3485713bc7 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::*; use crate::goals::GoalRuntimeState; +use codex_protocol::SessionId; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ThreadSource; @@ -363,7 +364,7 @@ impl Session { } else { ThreadEventPersistenceMode::Limited }; - let conversation_id = match &initial_history { + let thread_id = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { ThreadId::default() } @@ -394,7 +395,7 @@ impl Session { LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { - thread_id: conversation_id, + thread_id, forked_from_id, source: session_source, thread_source: session_configuration.thread_source, @@ -508,7 +509,7 @@ impl Session { let trace_task_name = (!trace_agent_path.is_root()).then(|| trace_agent_path.name().to_string()); let trace_metadata = ThreadStartedTraceMetadata { - thread_id: conversation_id.to_string(), + thread_id: thread_id.to_string(), agent_path: trace_agent_path.to_string(), task_name: trace_task_name, nickname: session_configuration.session_source.get_nickname(), @@ -599,7 +600,7 @@ impl Session { auth_manager.codex_api_key_env_enabled(), ); let mut session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, session_model.as_str(), session_model.as_str(), account_id.clone(), @@ -615,7 +616,7 @@ impl Session { session_telemetry = session_telemetry.with_metrics_service_name(service_name); } let network_proxy_audit_metadata = NetworkProxyAuditMetadata { - conversation_id: Some(conversation_id.to_string()), + conversation_id: Some(thread_id.to_string()), app_version: Some(env!("CARGO_PKG_VERSION").to_string()), user_account_id: account_id, auth_mode: auth_mode.map(|mode| mode.to_string()), @@ -685,7 +686,7 @@ impl Session { } else { ShellSnapshot::start_snapshotting( config.codex_home.clone(), - conversation_id, + thread_id, session_configuration.cwd.clone(), &mut default_shell, session_telemetry.clone(), @@ -698,7 +699,7 @@ impl Session { tx }; let thread_name = - thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, conversation_id) + thread_title_from_state_db(state_db_ctx.as_ref(), &config.codex_home, thread_id) .instrument(info_span!( "session_init.thread_name_lookup", otel.name = "session_init.thread_name_lookup", @@ -706,7 +707,7 @@ impl Session { .await; session_configuration.thread_name = thread_name.clone(); validate_config_lock_if_configured(&session_configuration).await?; - export_config_lock_if_configured(&session_configuration, conversation_id).await?; + export_config_lock_if_configured(&session_configuration, thread_id).await?; let state = SessionState::new(session_configuration.clone()); let managed_network_requirements_configured = config .config_layer_stack @@ -786,6 +787,12 @@ impl Session { config.analytics_enabled, ) }); + let session_id = if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + }; + let agent_control = agent_control.with_session_id(session_id); let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -830,7 +837,8 @@ impl Session { thread_store: Arc::clone(&thread_store), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, + session_id, + thread_id, installation_id, session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -850,7 +858,7 @@ impl Session { let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { - conversation_id, + conversation_id: thread_id, tx_event: tx_event.clone(), agent_status, out_of_band_elicitation_paused, @@ -878,7 +886,8 @@ impl Session { let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id, thread_source: session_configuration.thread_source, thread_name: session_configuration.thread_name.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 60c7278ea0..3384a09598 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -30,6 +30,7 @@ use codex_models_manager::model_info; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; use codex_protocol::AgentPath; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::config_types::ServiceTier; @@ -393,10 +394,12 @@ async fn interrupting_regular_turn_waiting_on_startup_prewarm_emits_turn_aborted } fn test_model_client_session() -> crate::client::ModelClientSession { + let thread_id = ThreadId::try_from("00000000-0000-4000-8000-000000000001") + .expect("test thread id should be valid"); crate::client::ModelClient::new( /*auth_manager*/ None, - ThreadId::try_from("00000000-0000-4000-8000-000000000001") - .expect("test thread id should be valid"), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, @@ -3605,7 +3608,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -3671,7 +3674,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -3743,7 +3746,8 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { )), model_client: ModelClient::new( Some(auth_manager.clone()), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -3772,7 +3776,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Session::make_turn_context( - conversation_id, + thread_id, Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -3793,7 +3797,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { - conversation_id, + conversation_id: thread_id, tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -3931,6 +3935,178 @@ async fn make_session_with_config_and_rx( Ok((session, rx_event, codex_home)) } +async fn make_session_with_history_source_and_agent_control_and_rx( + initial_history: InitialHistory, + session_source: SessionSource, + agent_control: AgentControl, +) -> anyhow::Result<(Arc, async_channel::Receiver)> { + let codex_home = tempfile::tempdir().expect("create temp dir"); + let mut config = build_test_config(codex_home.path()).await; + config.ephemeral = true; + let config = Arc::new(config); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + let models_manager = models_manager_with_provider( + config.codex_home.to_path_buf(), + auth_manager.clone(), + config.model_provider.clone(), + ); + let model = get_model_offline_for_tests(config.model.as_deref()); + let model_info = + construct_model_info_offline_for_tests(model.as_str(), &config.to_models_manager_config()); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model, + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; + let default_environments = vec![TurnEnvironmentSelection { + environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), + cwd: config.cwd.clone(), + }]; + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + collaboration_mode, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), + user_instructions: config.user_instructions.clone(), + service_tier: None, + personality: config.personality, + base_instructions: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + compact_prompt: config.compact_prompt.clone(), + approval_policy: config.permissions.approval_policy.clone(), + approvals_reviewer: config.approvals_reviewer, + permission_profile: config.permissions.permission_profile.clone(), + active_permission_profile: config.permissions.active_permission_profile(), + windows_sandbox_level: WindowsSandboxLevel::from_config(&config), + cwd: config.cwd.clone(), + codex_home: config.codex_home.clone(), + thread_name: None, + environments: default_environments, + original_config_do_not_use: Arc::clone(&config), + metrics_service_name: None, + app_server_client_name: None, + app_server_client_version: None, + session_source: session_source.clone(), + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + inherited_shell_snapshot: None, + user_shell_override: None, + }; + + let (tx_event, rx_event) = async_channel::unbounded(); + let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); + let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf())); + let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); + let skills_manager = Arc::new(SkillsManager::new( + config.codex_home.clone(), + /*bundled_skills_enabled*/ true, + )); + + let session = Session::new( + session_configuration, + Arc::clone(&config), + auth_manager, + models_manager, + Arc::new(ExecPolicyManager::default()), + tx_event, + agent_status_tx, + initial_history, + session_source, + skills_manager, + plugins_manager, + mcp_manager, + Arc::new(SkillsWatcher::noop()), + agent_control, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + /*state_db*/ None, + Arc::new(codex_thread_store::LocalThreadStore::new( + codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"), + )), + codex_rollout_trace::ThreadTraceContext::disabled(), + ) + .await?; + + Ok((session, rx_event)) +} + +#[tokio::test] +async fn resumed_root_session_uses_thread_id_as_session_id() { + let thread_id = ThreadId::new(); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + SessionSource::Exec, + AgentControl::default(), + ) + .await + .expect("resume should succeed"); + + assert_eq!( + session.services.agent_control.session_id(), + SessionId::from(thread_id) + ); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, SessionId::from(thread_id)); + assert_eq!(event.thread_id, thread_id); +} + +#[tokio::test] +async fn resumed_subagent_session_keeps_inherited_session_id() { + let parent_thread_id = ThreadId::new(); + let parent_session_id = SessionId::from(parent_thread_id); + let thread_id = ThreadId::new(); + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }); + let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( + InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history: Vec::new(), + rollout_path: None, + }), + session_source, + AgentControl::default().with_session_id(parent_session_id), + ) + .await + .expect("resume should succeed"); + + assert_eq!( + session.services.agent_control.session_id(), + parent_session_id + ); + + let event = rx_event.recv().await.expect("session configured event"); + let EventMsg::SessionConfigured(event) = event.msg else { + panic!("expected session configured event"); + }; + assert_eq!(event.session_id, parent_session_id); + assert_eq!(event.thread_id, thread_id); +} + #[tokio::test] async fn notify_request_permissions_response_ignores_unmatched_call_id() { let (session, _turn_context) = make_session_and_context().await; @@ -5117,7 +5293,7 @@ where let mut config = build_test_config(codex_home.as_path()).await; configure_config(&mut config); let config = Arc::new(config); - let conversation_id = ThreadId::default(); + let thread_id = ThreadId::default(); let auth_manager = AuthManager::from_auth_for_testing(auth); let models_manager = models_manager_with_provider( config.codex_home.to_path_buf(), @@ -5183,7 +5359,7 @@ where &per_turn_config.to_models_manager_config(), ); let session_telemetry = session_telemetry( - conversation_id, + thread_id, config.as_ref(), &model_info, session_configuration.session_source.clone(), @@ -5256,7 +5432,8 @@ where )), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), @@ -5285,7 +5462,7 @@ where ); let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Arc::new(Session::make_turn_context( - conversation_id, + thread_id, Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -5306,7 +5483,7 @@ where let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { - conversation_id, + conversation_id: thread_id, tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -7767,7 +7944,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a ) .await?; let persisted_goal = state_db - .get_thread_goal(test.session_configured.session_id) + .get_thread_goal(test.session_configured.thread_id) .await? .expect("goal should be persisted"); assert_eq!( diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 893d38ed83..af99790a1f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -80,13 +80,13 @@ async fn responses_stream_includes_subagent_header_on_review() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Review); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -100,7 +100,8 @@ async fn responses_stream_includes_subagent_header_on_review() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -141,7 +142,7 @@ async fn responses_stream_includes_subagent_header_on_review() { } let request = request_recorder.single_request(); - let expected_window_id = format!("{conversation_id}:0"); + let expected_window_id = format!("{thread_id}:0"); assert_eq!( request.header("x-openai-subagent").as_deref(), Some("review") @@ -205,14 +206,14 @@ async fn responses_stream_includes_subagent_header_on_other() { config.model = Some(model.clone()); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = TelemetryAuthMode::Chatgpt; let session_source = SessionSource::SubAgent(SubAgentSource::Other("my-task".to_string())); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -226,7 +227,8 @@ async fn responses_stream_includes_subagent_header_on_other() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, @@ -317,7 +319,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model = config.model.clone().expect("model configured"); let config = Arc::new(config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_mode = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")) .auth_mode() @@ -327,7 +329,7 @@ async fn responses_respects_model_info_overrides_from_config() { let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -341,7 +343,8 @@ async fn responses_respects_model_info_overrides_from_config() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 96cc1f3a99..2538c850e3 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -807,7 +807,7 @@ async fn wait_for_spawned_thread(test: &TestCodex) -> Result> { let ids = test.thread_manager.list_thread_ids().await; if let Some(thread_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return test .thread_manager diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index dafde4ccea..6f1ab3d672 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -726,7 +726,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn includes_conversation_id_and_model_headers_in_request() { +async fn includes_session_id_thread_id_and_model_headers_in_request() { skip_if_no_network!(); // Mock server @@ -744,7 +744,8 @@ async fn includes_conversation_id_and_model_headers_in_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let session_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -764,6 +765,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request = resp_mock.single_request(); assert_eq!(request.path(), "/v1/responses"); let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let request_authorization = request .header("authorization") .expect("authorization header"); @@ -772,10 +774,16 @@ async fn includes_conversation_id_and_model_headers_in_request() { let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); + let thread_id_string = expected_thread_id.to_string(); - assert_eq!(request_session_id, session_id.to_string()); + assert_eq!(request_session_id, expected_session_id.to_string()); + assert_eq!(request_thread_id, thread_id_string.as_str()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); + assert_eq!( + request_body["prompt_cache_key"].as_str(), + Some(thread_id_string.as_str()) + ); assert_eq!( request_body["client_metadata"]["x-codex-installation-id"].as_str(), Some(installation_id.as_str()) @@ -867,9 +875,9 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -884,7 +892,8 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth Some(AuthManager::from_auth_for_testing(CodexAuth::from_api_key( "unused-api-key", ))), - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, @@ -1000,7 +1009,8 @@ async fn chatgpt_auth_sends_correct_request() { .await .expect("create new conversation"); let codex = test.codex.clone(); - let thread_id = test.session_configured.session_id; + let expected_session_id = test.session_configured.session_id; + let expected_thread_id = test.session_configured.thread_id; codex .submit(Op::UserInput { @@ -1028,11 +1038,13 @@ async fn chatgpt_auth_sends_correct_request() { .expect("chatgpt-account-id header"); let request_body = request.body_json(); - let session_id = request.header("session_id").expect("session_id header"); + let request_session_id = request.header("session_id").expect("session_id header"); + let request_thread_id = request.header("thread_id").expect("thread_id header"); let installation_id = std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) .expect("read installation id"); - assert_eq!(session_id, thread_id.to_string()); + assert_eq!(request_session_id, expected_session_id.to_string()); + assert_eq!(request_thread_id, expected_thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); @@ -2277,11 +2289,11 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(model.as_str(), &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, model.as_str(), model_info.slug.as_str(), /*account_id*/ None, @@ -2295,7 +2307,8 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider.clone(), SessionSource::Exec, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index cdbb65aabd..7ef571af37 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -15,6 +15,7 @@ use codex_otel::MetricsConfig; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::ReasoningSummary; @@ -87,7 +88,8 @@ fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTr struct WebsocketTestHarness { _codex_home: TempDir, client: ModelClient, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, model_info: ModelInfo, effort: Option, summary: ReasoningSummary, @@ -125,7 +127,15 @@ async fn responses_websocket_streams_request() { ); assert_eq!( handshake.header(X_CLIENT_REQUEST_ID_HEADER), - Some(harness.conversation_id.to_string()) + Some(harness.thread_id.to_string()) + ); + assert_eq!( + handshake.header("session_id"), + Some(harness.session_id.to_string()) + ); + assert_eq!( + handshake.header("thread_id"), + Some(harness.thread_id.to_string()) ); assert_eq!( handshake.header(USER_AGENT_HEADER), @@ -1827,7 +1837,8 @@ async fn websocket_harness_with_provider_options( } let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); + let session_id = SessionId::new(); let auth_manager = codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("Test API Key")); let exporter = InMemoryMetricExporter::default(); @@ -1837,7 +1848,7 @@ async fn websocket_harness_with_provider_options( ) .expect("in-memory metrics client"); let session_telemetry = SessionTelemetry::new( - conversation_id, + thread_id, MODEL, model_info.slug.as_str(), /*account_id*/ None, @@ -1853,7 +1864,8 @@ async fn websocket_harness_with_provider_options( let summary = ReasoningSummary::Auto; let client = ModelClient::new( /*auth_manager*/ None, - conversation_id, + session_id, + thread_id, /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), SessionSource::Exec, @@ -1866,7 +1878,8 @@ async fn websocket_harness_with_provider_options( WebsocketTestHarness { _codex_home: codex_home, client, - conversation_id, + session_id, + thread_id, model_info, effort, summary, diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 38671bd763..c0a2a89394 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -273,6 +273,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); let responses_mock = responses::mount_sse_sequence( harness.server(), @@ -341,6 +342,10 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { compact_request.header("session_id").as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_request.header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let compact_body = compact_request.body_json(); assert_eq!( compact_body.get("model").and_then(|v| v.as_str()), @@ -713,6 +718,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { .await?; let codex = harness.test().codex.clone(); let session_id = harness.test().session_configured.session_id.to_string(); + let thread_id = harness.test().session_configured.thread_id.to_string(); mount_sse_once( harness.server(), @@ -764,6 +770,10 @@ async fn remote_compact_runs_automatically() -> Result<()> { .as_deref(), Some(session_id.as_str()) ); + assert_eq!( + compact_mock.single_request().header("thread_id").as_deref(), + Some(thread_id.as_str()) + ); let follow_up_request = responses_mock.single_request(); let follow_up_body = follow_up_request.body_json().to_string(); assert!(follow_up_body.contains("REMOTE_COMPACTED_SUMMARY")); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 91d321b540..37456dce63 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -195,7 +195,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { ForkSnapshot::Interrupted, test.config.clone(), InitialHistory::Resumed(ResumedHistory { - conversation_id: test.session_configured.session_id, + conversation_id: test.session_configured.thread_id, history: source_items.clone(), rollout_path: None, }), diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 9530675acd..65087a1fe7 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -358,7 +358,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { let call_id = "ig_image_saved_to_temp_dir_default"; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), call_id, ); let _ = std::fs::remove_file(&expected_saved_path); @@ -444,7 +444,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho } = test_codex().build(&server).await?; let expected_saved_path = image_generation_artifact_path( config.codex_home.as_path(), - &session_configured.session_id.to_string(), + &session_configured.thread_id.to_string(), "ig_invalid", ); let _ = std::fs::remove_file(&expected_saved_path); @@ -547,8 +547,8 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { }) .await; - let session_id = session_configured.session_id.to_string(); - assert_eq!(delta_event.thread_id, session_id); + let thread_id = session_configured.thread_id.to_string(); + assert_eq!(delta_event.thread_id, thread_id); assert_eq!(delta_event.turn_id, started_turn_id); assert_eq!(delta_event.item_id, started_item.id); assert_eq!(delta_event.delta, "streamed response"); @@ -614,7 +614,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result< assert_eq!( plan_delta.thread_id, - session_configured.session_id.to_string() + session_configured.thread_id.to_string() ); assert_eq!(plan_delta.delta, "- Step 1\n- Step 2\n"); assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n"); diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b0df5050c7..e962b06d41 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -487,7 +487,7 @@ async fn generated_image_is_replayed_for_image_capable_models() -> Result<()> { let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -601,7 +601,7 @@ async fn model_change_from_generated_image_to_text_preserves_prior_generated_ima let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_123", ); let _ = std::fs::remove_file(&saved_path); @@ -717,7 +717,7 @@ async fn thread_rollback_after_generated_image_drops_entire_image_turn_history() let test = builder.build(&server).await?; let saved_path = image_generation_artifact_path( test.codex_home_path(), - &test.session_configured.session_id.to_string(), + &test.session_configured.thread_id.to_string(), "ig_rollback", ); let _ = std::fs::remove_file(&saved_path); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index eda970d0b2..307d066928 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -596,7 +596,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - ); assert_eq!( apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), - Some(&json!(test.session_configured.session_id.to_string())) + Some(&json!(test.session_configured.thread_id.to_string())) ); assert!( apps_tool_call diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index 20fa8a8f0e..b2a2778047 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -48,7 +48,7 @@ async fn new_thread_is_recorded_in_state_db() -> Result<()> { }); let test = builder.build(&server).await?; - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let rollout_path = test.codex.rollout_path().expect("rollout path"); let db_path = codex_state::state_db_path(test.config.sqlite_home.as_path()); @@ -262,7 +262,7 @@ async fn user_messages_persist_in_state_db() -> Result<()> { test.submit_turn("another message").await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let mut metadata = None; for _ in 0..100 { @@ -305,7 +305,7 @@ async fn web_search_marks_thread_memory_mode_polluted_when_configured() -> Resul }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; test.submit_turn("search the web").await?; @@ -397,7 +397,7 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result< }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let thread_id = test.session_configured.session_id; + let thread_id = test.session_configured.thread_id; let cwd = test.cwd_path().to_path_buf(); let (sandbox_policy, permission_profile) = turn_permission_fields(PermissionProfile::read_only(), cwd.as_path()); @@ -478,7 +478,7 @@ async fn tool_call_logs_include_thread_id() -> Result<()> { }); let test = builder.build(&server).await?; let db = test.codex.state_db().expect("state db enabled"); - let expected_thread_id = test.session_configured.session_id.to_string(); + let expected_thread_id = test.session_configured.thread_id.to_string(); test.submit_turn("run a shell command").await?; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 3f457967c1..3a0c37acc7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -116,7 +116,7 @@ async fn wait_for_spawned_thread_id(test: &TestCodex) -> Result { let ids = test.thread_manager.list_thread_ids().await; if let Some(spawned_id) = ids .iter() - .find(|id| **id != test.session_configured.session_id) + .find(|id| **id != test.session_configured.thread_id) { return Ok(spawned_id.to_string()); } diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 1641398ae6..045baacc7b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -392,7 +392,7 @@ impl EventProcessorWithJsonOutput { pub fn thread_started_event(session_configured: &SessionConfiguredEvent) -> ThreadEvent { ThreadEvent::ThreadStarted(ThreadStartedEvent { - thread_id: session_configured.session_id.to_string(), + thread_id: session_configured.thread_id.to_string(), }) } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0db63b8034..5ff19c744b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -78,6 +78,8 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; use codex_otel::traceparent_context_from_env; +use codex_protocol::SessionId; +use codex_protocol::ThreadId; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; @@ -694,7 +696,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_resume_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } else { let response: ThreadStartResponse = send_request_with_response( &client, @@ -709,7 +711,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) } } else { let response: ThreadStartResponse = send_request_with_response( @@ -724,7 +726,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; let session_configured = session_configured_from_thread_start_response(&response, &config) .map_err(anyhow::Error::msg)?; - (session_configured.session_id, session_configured) + (session_configured.thread_id, session_configured) }; let primary_thread_id_for_span = primary_thread_id.to_string(); @@ -1058,6 +1060,7 @@ fn session_configured_from_thread_start_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -1082,6 +1085,7 @@ fn session_configured_from_thread_resume_response( config: &Config, ) -> Result { session_configured_from_thread_response( + &response.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -1115,6 +1119,7 @@ fn review_target_to_api(target: ReviewTarget) -> ApiReviewTarget { reason = "session mapping keeps explicit fields" )] fn session_configured_from_thread_response( + session_id: &str, thread_id: &str, thread_name: Option, rollout_path: Option, @@ -1128,11 +1133,14 @@ fn session_configured_from_thread_response( cwd: AbsolutePathBuf, reasoning_effort: Option, ) -> Result { - let session_id = codex_protocol::ThreadId::from_string(thread_id) + let session_id = SessionId::from_string(session_id) + .map_err(|err| format!("session id `{session_id}` is invalid: {err}"))?; + let thread_id = ThreadId::from_string(thread_id) .map_err(|err| format!("thread id `{thread_id}` is invalid: {err}"))?; Ok(SessionConfiguredEvent { session_id, + thread_id, forked_from_id: None, thread_source: None, thread_name, diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index b35f6e9bfa..ad528a194d 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -441,6 +441,14 @@ async fn session_configured_from_thread_response_uses_review_policy_from_respons let event = session_configured_from_thread_start_response(&response, &config) .expect("build bootstrap session configured event"); + assert_eq!( + event.session_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c7" + ); + assert_eq!( + event.thread_id.to_string(), + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ); assert_eq!(event.approvals_reviewer, ApprovalsReviewer::AutoReview); } @@ -465,6 +473,7 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), forked_from_id: None, 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 e066cb6ddc..2e6560be94 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::PermissionProfile; use codex_protocol::models::WebSearchAction; @@ -104,9 +105,11 @@ fn map_todo_items_preserves_text_and_completion_state() { #[test] fn session_configured_produces_thread_started_event() { + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse"); let session_configured = SessionConfiguredEvent { - session_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") - .expect("thread id should parse"), + session_id: SessionId::from(thread_id), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 1ab6b1a0af..c9ad91bbc6 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -296,7 +296,8 @@ mod tests { let event = Event { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -339,10 +340,11 @@ mod tests { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: conversation_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -387,6 +389,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", @@ -411,7 +414,8 @@ mod tests { let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { - session_id: thread_id, + session_id: codex_protocol::SessionId::new(), + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -457,6 +461,7 @@ mod tests { "msg": { "type": "session_configured", "session_id": session_configured_event.session_id, + "thread_id": session_configured_event.thread_id, "model": "gpt-4o", "model_provider_id": "test-provider", "approval_policy": "never", diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 5db8761058..2e2c664e64 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -15,6 +15,7 @@ use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_login::default_client::originator; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; @@ -174,6 +175,7 @@ impl MemoryStartupContext { let session_source = self.thread.config_snapshot().await.session_source; let model_client = ModelClient::new( Some(Arc::clone(&self.auth_manager)), + SessionId::from(self.thread_id), self.thread_id, installation_id, config.model_provider.clone(), diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index d89b68825e..f80b891ddc 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -265,7 +265,7 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu let context = crate::runtime::MemoryStartupContext::new( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), &test.config, config_snapshot.session_source.clone(), @@ -317,7 +317,7 @@ async fn trigger_memories_startup(test: &TestCodex) { start_memories_startup_task( Arc::clone(&test.thread_manager), test.thread_manager.auth_manager(), - test.session_configured.session_id, + test.session_configured.thread_id, Arc::clone(&test.codex), Arc::new(config), &config_snapshot.session_source, diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 175c92331f..a945b1a927 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -1,9 +1,11 @@ pub mod account; mod agent_path; pub mod auth; +mod session_id; mod thread_id; mod tool_name; pub use agent_path::AgentPath; +pub use session_id::SessionId; pub use thread_id::ThreadId; pub use tool_name::ToolName; pub mod approvals; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 09be839f93..017eca8c59 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -14,6 +14,7 @@ use std::time::Duration; use strum_macros::EnumIter; use crate::AgentPath; +use crate::SessionId; use crate::ThreadId; use crate::approvals::ElicitationRequestEvent; use crate::config_types::ApprovalsReviewer; @@ -3456,7 +3457,8 @@ pub struct SessionNetworkProxyRuntime { #[derive(Debug, Clone, Serialize, JsonSchema, TS)] pub struct SessionConfiguredEvent { - pub session_id: ThreadId, + pub session_id: SessionId, + pub thread_id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, /// Optional analytics source classification for this thread. @@ -3530,7 +3532,9 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { { #[derive(Deserialize)] struct Wire { - session_id: ThreadId, + session_id: SessionId, + #[serde(default)] + thread_id: Option, forked_from_id: Option, #[serde(default)] thread_source: Option, @@ -3572,6 +3576,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { Ok(Self { session_id: wire.session_id, + thread_id: wire.thread_id.unwrap_or_else(|| wire.session_id.into()), forked_from_id: wire.forked_from_id, thread_source: wire.thread_source, thread_name: wire.thread_name, @@ -5294,13 +5299,15 @@ mod tests { /// amount of nesting. #[test] fn serialize_event() -> Result<()> { - let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let session_id = SessionId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c7")?; + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let rollout_file = NamedTempFile::new()?; let permission_profile = PermissionProfile::read_only(); let event = Event { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { - session_id: conversation_id, + session_id, + thread_id, forked_from_id: None, thread_source: None, thread_name: None, @@ -5325,7 +5332,8 @@ mod tests { "id": "1234", "msg": { "type": "session_configured", - "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c7", + "thread_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "codex-mini-latest", "model_provider_id": "openai", "approval_policy": "never", diff --git a/codex-rs/protocol/src/session_id.rs b/codex-rs/protocol/src/session_id.rs new file mode 100644 index 0000000000..ac22103d33 --- /dev/null +++ b/codex-rs/protocol/src/session_id.rs @@ -0,0 +1,126 @@ +use std::fmt::Display; + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +use crate::ThreadId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] +#[ts(type = "string")] +pub struct SessionId { + pub(crate) uuid: Uuid, +} + +impl SessionId { + pub fn new() -> Self { + Self { + uuid: Uuid::now_v7(), + } + } + + pub fn from_string(s: &str) -> Result { + Ok(Self { + uuid: Uuid::parse_str(s)?, + }) + } +} + +impl TryFrom<&str> for SessionId { + type Error = uuid::Error; + + fn try_from(value: &str) -> Result { + Self::from_string(value) + } +} + +impl TryFrom for SessionId { + type Error = uuid::Error; + + fn try_from(value: String) -> Result { + Self::from_string(value.as_str()) + } +} + +impl From for String { + fn from(value: SessionId) -> Self { + value.to_string() + } +} + +impl From for SessionId { + fn from(value: ThreadId) -> Self { + Self { uuid: value.uuid } + } +} + +impl From for ThreadId { + fn from(value: SessionId) -> Self { + ThreadId { uuid: value.uuid } + } +} + +impl Default for SessionId { + fn default() -> Self { + Self::new() + } +} + +impl Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + Display::fmt(&self.uuid, f) + } +} + +impl Serialize for SessionId { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.collect_str(&self.uuid) + } +} + +impl<'de> Deserialize<'de> for SessionId { + 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 }) + } +} + +impl JsonSchema for SessionId { + fn schema_name() -> String { + "SessionId".to_string() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_id_default_is_not_zeroes() { + let id = SessionId::default(); + assert_ne!(id.uuid, Uuid::nil()); + } + + #[test] + fn converts_to_and_from_thread_id() { + let thread_id = ThreadId::new(); + let session_id = SessionId::from(thread_id); + + assert_eq!(ThreadId::from(session_id), thread_id); + } +} diff --git a/codex-rs/protocol/src/thread_id.rs b/codex-rs/protocol/src/thread_id.rs index 8d6d96eff8..d6e9a8825e 100644 --- a/codex-rs/protocol/src/thread_id.rs +++ b/codex-rs/protocol/src/thread_id.rs @@ -11,7 +11,7 @@ use uuid::Uuid; #[derive(Debug, Clone, Copy, PartialEq, Eq, TS, Hash)] #[ts(type = "string")] pub struct ThreadId { - uuid: Uuid, + pub(crate) uuid: Uuid, } impl ThreadId { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9aafe9aed3..12f2fdcf62 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1813,6 +1813,7 @@ mod tests { let forked_from_id = ThreadId::new(); let read_only_profile = PermissionProfile::read_only(); let response = ThreadResumeResponse { + session_id: ThreadId::new().to_string(), thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), forked_from_id: Some(forked_from_id.to_string()), diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 76b2b29bc0..0fa4567d9f 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -12,10 +12,10 @@ use pretty_assertions::assert_eq; async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -95,7 +95,7 @@ async fn submission_preserves_text_elements_and_local_images() { async fn submission_includes_configured_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -119,7 +119,7 @@ async fn submission_includes_configured_permission_profile() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -161,7 +161,7 @@ async fn submission_includes_configured_permission_profile() { async fn submission_keeps_profile_when_legacy_projection_is_external() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = AppServerPermissionProfile::Managed { network: PermissionProfileNetworkPermissions { enabled: false }, @@ -169,7 +169,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { } .into(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -208,10 +208,10 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -302,10 +302,10 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi async fn enter_with_only_remote_images_submits_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -366,10 +366,10 @@ async fn enter_with_only_remote_images_submits_user_turn() { async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -405,10 +405,10 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -444,10 +444,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -486,10 +486,10 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { async fn submission_prefers_selected_duplicate_skill_path() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index c9789be14d..40ccadb7c3 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -941,10 +941,10 @@ async fn user_shell_command_renders_output_not_exploring() { #[tokio::test] async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8c31175998..93e8e62f07 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -14,10 +14,10 @@ use pretty_assertions::assert_eq; async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -85,10 +85,10 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -154,10 +154,10 @@ async fn replayed_user_message_preserves_remote_image_urls() { let message = "replayed with remote image".to_string(); let remote_image_urls = vec!["https://example.com/image.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -350,10 +350,10 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, @@ -405,10 +405,10 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { let local_images = [PathBuf::from("/tmp/replay-local-only.png")]; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 8af07a6aa9..5fb10b8087 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1202,10 +1202,10 @@ async fn submit_user_message_queues_while_compaction_turn_is_running() { #[tokio::test(flavor = "multi_thread")] async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - let conversation_id = ThreadId::new(); + let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { - thread_id: conversation_id, + thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, From b5e965e1d75697f9033324b3c83a65c180d1238e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 11:21:22 +0200 Subject: [PATCH 09/30] test: isolate app-server-client in-process test state (#21328) ## Why The in-process `app-server-client` tests were still building their configs from the ambient `codex_home` and letting the embedded app server create its own state DB when `state_db` was absent. That matters because in-process startup falls back to `init_state_db_from_config(...)` in that case, so tests can otherwise share persisted state instead of getting isolated fixtures: [`app-server/src/in_process.rs`](https://github.com/openai/codex/blob/a98623511ba433154ec811fc63091617f5945438/codex-rs/app-server/src/in_process.rs#L368-L373). ## What changed - Give each in-process test client its own temporary `codex_home`. - Initialize the matching state DB from that per-client config and pass it into the client explicitly. - Keep the temp directory alive for the lifetime of the test client through a small `TestClient` wrapper. - Add `tempfile` as a dev dependency for the new harness. The updated setup lives in [`app-server-client/src/lib.rs`](https://github.com/openai/codex/blob/35c1133d45d10931914dbb88a1246a195d025ff6/codex-rs/app-server-client/src/lib.rs#L982-L1055). ## Testing - Existing `codex-app-server-client` tests continue to exercise the updated in-process client path through the isolated helper. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-client/Cargo.toml | 1 + codex-rs/app-server-client/src/lib.rs | 61 ++++++++++++++++++++++++--- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1b92b5afd0..b0d9cfe527 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1944,6 +1944,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "toml 0.9.11+spec-1.1.0", diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index d9c1ade097..fee29db47e 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -33,4 +33,5 @@ url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 0911cc448d..a22eed1c80 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -954,9 +954,13 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_core::config::ConfigBuilder; + use codex_core::init_state_db_from_config; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; + use std::ops::Deref; + use std::path::Path; + use tempfile::TempDir; use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; @@ -975,19 +979,59 @@ mod tests { } } + async fn build_test_config_for_codex_home(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + struct TestClient { + _codex_home: TempDir, + client: InProcessAppServerClient, + } + + impl Deref for TestClient { + type Target = InProcessAppServerClient; + + fn deref(&self) -> &Self::Target { + &self.client + } + } + + impl TestClient { + async fn shutdown(self) -> IoResult<()> { + self.client.shutdown().await + } + } + async fn start_test_client_with_capacity( session_source: SessionSource, channel_capacity: usize, - ) -> InProcessAppServerClient { - InProcessAppServerClient::start(InProcessClientStartArgs { + ) -> TestClient { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await); + let state_db = init_state_db_from_config(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, - state_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -999,10 +1043,15 @@ mod tests { channel_capacity, }) .await - .expect("in-process app-server client should start") + .expect("in-process app-server client should start"); + + TestClient { + _codex_home: codex_home, + client, + } } - async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient { + async fn start_test_client(session_source: SessionSource) -> TestClient { start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await } From fe24a180ab6f6b3639b682cc6a1e71150fea6d48 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 11:36:15 +0200 Subject: [PATCH 10/30] feat: include thread ID in MCP turn metadata (#21329) ## Why MCP tool calls already include `session_id` in `x-codex-turn-metadata`, but descendant threads intentionally share that value with the root thread. Consumers that need to correlate work at the concrete thread level also need the current `thread_id`. ## What changed - add `thread_id` to `x-codex-turn-metadata` while preserving `session_id` as the shared session identity - thread the two identities separately through normal turns and spawned review threads - add regression coverage for resumed sessions, reserved metadata fields, and deferred MCP tool calls ## Verification - added focused coverage in `core/src/session/tests.rs`, `core/src/turn_metadata_tests.rs`, and `core/tests/suite/search_tool.rs` --- codex-rs/core/src/session/review.rs | 3 ++- codex-rs/core/src/session/session.rs | 10 ++++++++++ codex-rs/core/src/session/tests.rs | 14 ++++++-------- codex-rs/core/src/session/turn_context.rs | 10 +++++++--- codex-rs/core/src/turn_metadata.rs | 9 +++++++++ codex-rs/core/src/turn_metadata_tests.rs | 10 ++++++++++ codex-rs/core/tests/suite/search_tool.rs | 4 ++++ 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index bd50d74bb6..7d4b1b736a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -102,7 +102,8 @@ pub(super) async fn spawn_review_thread( let per_turn_config = Arc::new(per_turn_config); let review_turn_id = sub_id.to_string(); let turn_metadata_state = Arc::new(TurnMetadataState::new( - sess.conversation_id.to_string(), + sess.session_id().to_string(), + sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3485713bc7..46f25cd1f0 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -325,6 +325,16 @@ pub(crate) struct AppServerClientMetadata { } impl Session { + /// Returns the concrete identity for this thread. + pub(crate) fn thread_id(&self) -> ThreadId { + self.conversation_id + } + + /// Returns the identity shared by the root thread and all descendant threads. + pub(crate) fn session_id(&self) -> SessionId { + self.services.agent_control.session_id() + } + #[instrument(name = "session_init", level = "info", skip_all)] #[allow(clippy::too_many_arguments)] #[expect( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3384a09598..26dca0c25f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3777,6 +3777,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Session::make_turn_context( thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), @@ -4057,10 +4058,8 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { .await .expect("resume should succeed"); - assert_eq!( - session.services.agent_control.session_id(), - SessionId::from(thread_id) - ); + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), SessionId::from(thread_id)); let event = rx_event.recv().await.expect("session configured event"); let EventMsg::SessionConfigured(event) = event.msg else { @@ -4094,10 +4093,8 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { .await .expect("resume should succeed"); - assert_eq!( - session.services.agent_control.session_id(), - parent_session_id - ); + assert_eq!(session.thread_id(), thread_id); + assert_eq!(session.session_id(), parent_session_id); let event = rx_event.recv().await.expect("session configured event"); let EventMsg::SessionConfigured(event) = event.msg else { @@ -5463,6 +5460,7 @@ where let turn_environments = turn_environments_for_tests(&environment, &session_configuration.cwd); let turn_context = Arc::new(Session::make_turn_context( thread_id, + SessionId::from(thread_id), Some(Arc::clone(&auth_manager)), &session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index e42fc2b102..8e78a0c9cd 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -4,6 +4,7 @@ use crate::config::GhostSnapshotConfig; use crate::environment_selection::ResolvedTurnEnvironments; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_protocol::SessionId; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; @@ -440,7 +441,8 @@ impl Session { #[allow(clippy::too_many_arguments)] pub(crate) fn make_turn_context( - conversation_id: ThreadId, + thread_id: ThreadId, + session_id: SessionId, auth_manager: Option>, session_telemetry: &SessionTelemetry, provider: ModelProviderInfo, @@ -522,7 +524,8 @@ impl Session { let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( - conversation_id.to_string(), + session_id.to_string(), + thread_id.to_string(), session_configuration.thread_source, sub_id.clone(), cwd.clone(), @@ -718,7 +721,8 @@ impl Session { ); let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( - self.conversation_id, + self.thread_id(), + self.session_id(), Some(Arc::clone(&self.services.auth_manager)), &self.services.session_telemetry, session_configuration.provider.clone(), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d4a46944eb..02760582f2 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -69,6 +69,8 @@ pub(crate) struct TurnMetadataBag { #[serde(default, skip_serializing_if = "Option::is_none")] session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] thread_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] turn_id: Option, @@ -115,6 +117,7 @@ fn merge_turn_metadata( fn build_turn_metadata_bag( session_id: Option, + thread_id: Option, thread_source: Option, turn_id: Option, sandbox: Option, @@ -130,6 +133,7 @@ fn build_turn_metadata_bag( TurnMetadataBag { session_id, + thread_id, thread_source, turn_id, workspaces, @@ -159,6 +163,7 @@ pub async fn build_turn_metadata_header( build_turn_metadata_bag( /*session_id*/ None, + /*thread_id*/ None, /*thread_source*/ None, /*turn_id*/ None, sandbox.map(ToString::to_string), @@ -185,8 +190,10 @@ pub(crate) struct TurnMetadataState { } impl TurnMetadataState { + #[allow(clippy::too_many_arguments)] pub(crate) fn new( session_id: String, + thread_id: String, thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, @@ -205,6 +212,7 @@ impl TurnMetadataState { ); let base_metadata = build_turn_metadata_bag( Some(session_id), + Some(thread_id), thread_source, Some(turn_id), sandbox, @@ -320,6 +328,7 @@ impl TurnMetadataState { let enriched_metadata = build_turn_metadata_bag( state.base_metadata.session_id.clone(), + state.base_metadata.thread_id.clone(), state.base_metadata.thread_source, state.base_metadata.turn_id.clone(), state.base_metadata.sandbox.clone(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index d840ade91b..2a38447f86 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -94,6 +94,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -106,11 +107,13 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { let json: Value = serde_json::from_str(&header).expect("json"); let sandbox_name = json.get("sandbox").and_then(Value::as_str); let session_id = json.get("session_id").and_then(Value::as_str); + let thread_id = json.get("thread_id").and_then(Value::as_str); let thread_source = json.get("thread_source").and_then(Value::as_str); let expected_sandbox = sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled); assert_eq!(sandbox_name, Some(expected_sandbox)); assert_eq!(session_id, Some("session-a")); + assert_eq!(thread_id, Some("thread-a")); assert_eq!(thread_source, Some("user")); assert!(json.get("session_source").is_none()); } @@ -122,6 +125,7 @@ fn turn_metadata_state_uses_explicit_subagent_thread_source() { let permission_profile = PermissionProfile::read_only(); let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::Subagent), "turn-a".to_string(), cwd, @@ -145,6 +149,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -171,6 +176,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), /*thread_source*/ None, "turn-a".to_string(), cwd, @@ -215,6 +221,7 @@ fn turn_metadata_state_ignores_client_turn_started_at_unix_ms_before_start() { let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -241,6 +248,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let state = TurnMetadataState::new( "session-a".to_string(), + "thread-a".to_string(), Some(ThreadSource::User), "turn-a".to_string(), cwd, @@ -257,6 +265,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( "client-supplied".to_string(), ), ("session_id".to_string(), "client-supplied".to_string()), + ("thread_id".to_string(), "client-supplied".to_string()), ("thread_source".to_string(), "client-supplied".to_string()), ( "turn_started_at_unix_ms".to_string(), @@ -275,6 +284,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( assert_eq!(json["model"].as_str(), Some("client-supplied")); assert_eq!(json["reasoning_effort"].as_str(), Some("client-supplied")); assert_eq!(json["session_id"].as_str(), Some("session-a")); + assert_eq!(json["thread_id"].as_str(), Some("thread-a")); assert_eq!(json["thread_source"].as_str(), Some("user")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!( diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 307d066928..5e2c7db346 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -596,6 +596,10 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - ); assert_eq!( apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/session_id"), + Some(&json!(test.session_configured.session_id.to_string())) + ); + assert_eq!( + apps_tool_call.pointer("/params/_meta/x-codex-turn-metadata/thread_id"), Some(&json!(test.session_configured.thread_id.to_string())) ); assert!( From 06e5dfa4dd9fc6fb30d65553757060428c9102c2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 12:04:27 +0200 Subject: [PATCH 11/30] feat: return session ID from thread/fork (#21332) ## Why `thread/start` and `thread/resume` already return `sessionId`, but `thread/fork` only returned the new thread. That left clients to infer the forked thread's session identity from `thread.id`, which kept the new `session_id` / `thread_id` split implicit at one lifecycle boundary. Follow-up to #20437. ## What changed - Add `sessionId` to `ThreadForkResponse`. - Populate it from the forked session configuration. - Regenerate the v2 JSON/TypeScript schema fixtures and update the app-server docs/example. - Extend the fork integration test to assert the returned `sessionId`. ## Verification - Added coverage in `thread_fork_creates_new_thread_and_emits_started` for the new response field. --- codex-rs/analytics/src/client_tests.rs | 1 + .../schema/json/codex_app_server_protocol.schemas.json | 5 +++++ .../schema/json/codex_app_server_protocol.v2.schemas.json | 5 +++++ .../schema/json/v2/ThreadForkResponse.json | 5 +++++ .../schema/typescript/v2/ThreadForkResponse.ts | 5 ++++- codex-rs/app-server-protocol/src/protocol/v2/thread.rs | 3 +++ codex-rs/app-server/README.md | 4 ++-- .../app-server/src/request_processors/thread_processor.rs | 1 + codex-rs/app-server/tests/suite/v2/thread_fork.rs | 5 ++++- 9 files changed, 30 insertions(+), 4 deletions(-) diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 42d508602a..510378c20d 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -138,6 +138,7 @@ fn sample_thread_resume_response() -> ClientResponsePayload { fn sample_thread_fork_response() -> ClientResponsePayload { ClientResponsePayload::ThreadFork(ThreadForkResponse { + session_id: "session-3".to_string(), thread: sample_thread("thread-3"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), 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 d6f184a6db..20279bf010 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 @@ -15664,6 +15664,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/v2/Thread" } 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 1f252cbd23..50a19200a5 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 @@ -13550,6 +13550,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 05f9849df3..902a6f4c9f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -2619,6 +2619,11 @@ } ] }, + "sessionId": { + "default": "", + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..b0bb9caea6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -9,7 +9,10 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** 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 d8250987a6..5a09c2e665 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -419,6 +419,9 @@ pub struct ThreadForkParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadForkResponse { + /// Session id shared by threads that belong to the same session tree. + #[serde(default)] + pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 60ff4573aa..8aeeb7df4f 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -303,11 +303,11 @@ Example: { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The response includes the forked thread's `sessionId`, so clients do not need to infer it from the new thread id. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "sessionId": "thr_456", "thread": { "id": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` 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 5563bd56e9..7cef88b14b 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -3116,6 +3116,7 @@ impl ThreadRequestProcessor { thread_response_active_permission_profile(config_snapshot.active_permission_profile); let response = ThreadForkResponse { + session_id: session_configured.session_id.to_string(), thread: thread.clone(), model: session_configured.model, model_provider: session_configured.model_provider_id, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 5c4ba3a3e3..4a6644e30a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -101,7 +101,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ) .await??; let fork_result = fork_resp.result.clone(); - let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + let ThreadForkResponse { + session_id, thread, .. + } = to_response::(fork_resp)?; // Wire contract: thread title field is `name`, serialized as null when unset. let thread_json = fork_result @@ -121,6 +123,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(session_id, thread.id); assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); From cc84e6bc6dca7d9f668028e71576457975d4d246 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 12:33:37 +0200 Subject: [PATCH 12/30] Revert "feat: support template interpolation in multi-agent usage hints" (#21337) Reverts openai/codex#20973 --- codex-rs/core/src/config/config_tests.rs | 62 ------ codex-rs/core/src/config/mod.rs | 58 ------ .../core/src/config/template_interpolation.rs | 189 ------------------ codex-rs/core/src/session/config_lock.rs | 89 ++++++++- 4 files changed, 85 insertions(+), 313 deletions(-) delete mode 100644 codex-rs/core/src/config/template_interpolation.rs diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index aed305552d..7001f9015c 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8927,68 +8927,6 @@ hide_spawn_agent_metadata = true Ok(()) } -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_use_materialized_config_values() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "Limit {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -root_agent_usage_hint_text = "Root {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -subagent_usage_hint_text = "Subagent {{ features.multi_agent_v2.max_concurrent_threads_per_session }}" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("Limit 4") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("Root 4") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("Subagent 4") - ); - - Ok(()) -} - -#[tokio::test] -async fn multi_agent_v2_usage_hint_templates_fail_when_placeholder_is_missing() { - let codex_home = TempDir::new().expect("create codex home"); - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"[features.multi_agent_v2] -enabled = true -usage_hint_text = "{{ features.multi_agent_v2.does_not_exist }}" -"#, - ) - .expect("write config"); - - let err = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await - .expect_err("config load should fail"); - - assert!( - err.to_string() - .contains("features.multi_agent_v2.does_not_exist"), - "unexpected error: {err}", - ); -} - #[tokio::test] async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 657a0413c9..43d88bcdd2 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -133,7 +133,6 @@ mod network_proxy_spec; mod permissions; #[cfg(test)] mod schema; -pub(crate) mod template_interpolation; pub use codex_config::Constrained; pub use codex_config::ConstraintError; pub use codex_config::ConstraintResult; @@ -2083,63 +2082,6 @@ impl Config { overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, - ) -> std::io::Result { - let config = Self::build_config_with_layer_stack( - fs, - cfg.clone(), - overrides.clone(), - codex_home.clone(), - config_layer_stack.clone(), - ) - .await?; - let mut interpolation_source_cfg = cfg.clone(); - template_interpolation::apply_resolved_config_fields( - &config, - &mut interpolation_source_cfg, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to materialize config for interpolation: {err}"), - ) - })?; - let interpolation_source = - toml::Value::try_from(interpolation_source_cfg).map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to serialize config for interpolation: {err}"), - ) - })?; - let mut interpolated_cfg = cfg; - let interpolated = template_interpolation::interpolate_config_string_fields( - &mut interpolated_cfg, - &interpolation_source, - ) - .map_err(|err| { - std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!("failed to interpolate config template fields: {err}"), - ) - })?; - if interpolated { - return Self::build_config_with_layer_stack( - fs, - interpolated_cfg, - overrides, - codex_home, - config_layer_stack, - ) - .await; - } - Ok(config) - } - - async fn build_config_with_layer_stack( - fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, - overrides: ConfigOverrides, - codex_home: AbsolutePathBuf, - config_layer_stack: ConfigLayerStack, ) -> std::io::Result { // Keep the large config-construction future off small test thread stacks. Box::pin(async move { diff --git a/codex-rs/core/src/config/template_interpolation.rs b/codex-rs/core/src/config/template_interpolation.rs deleted file mode 100644 index 7e38fed86f..0000000000 --- a/codex-rs/core/src/config/template_interpolation.rs +++ /dev/null @@ -1,189 +0,0 @@ -use anyhow::Context; -use anyhow::bail; -use codex_config::config_toml::ConfigToml; -use codex_config::types::MemoriesToml; -use codex_features::AppsMcpPathOverrideConfigToml; -use codex_features::Feature; -use codex_features::FeatureToml; -use codex_features::FeaturesToml; -use codex_features::MultiAgentV2ConfigToml; -use codex_utils_template::Template; -use toml::Value as TomlValue; - -use super::Config; - -const INTERPOLATED_CONFIG_STRING_FIELDS: &[&str] = &[ - "features.multi_agent_v2.usage_hint_text", - "features.multi_agent_v2.root_agent_usage_hint_text", - "features.multi_agent_v2.subagent_usage_hint_text", -]; - -pub(crate) fn materialized_config_toml(config: &Config) -> anyhow::Result { - let mut materialized: ConfigToml = config - .config_layer_stack - .effective_config() - .try_into() - .context("failed to deserialize effective config for config interpolation")?; - apply_resolved_config_fields(config, &mut materialized)?; - Ok(materialized) -} - -pub(crate) fn interpolate_config_string_fields( - config_toml: &mut ConfigToml, - interpolation_source: &TomlValue, -) -> anyhow::Result { - let mut target_value = TomlValue::try_from(config_toml.clone()) - .context("failed to serialize config for interpolation")?; - let mut changed = false; - - for field_path in INTERPOLATED_CONFIG_STRING_FIELDS { - let Some(value) = value_mut_at_path(&mut target_value, field_path) else { - continue; - }; - let Some(template_source) = value.as_str() else { - bail!("interpolated config field `{field_path}` must be a string"); - }; - let template = Template::parse(template_source) - .with_context(|| format!("failed to parse template in config field `{field_path}`"))?; - let rendered = render_template(&template, interpolation_source, field_path)?; - if rendered != template_source { - *value = TomlValue::String(rendered); - changed = true; - } - } - - if changed { - *config_toml = target_value - .try_into() - .context("failed to deserialize interpolated config")?; - } - - Ok(changed) -} - -pub(crate) fn apply_resolved_config_fields( - config: &Config, - config_toml: &mut ConfigToml, -) -> anyhow::Result<()> { - config_toml.web_search = Some(config.web_search_mode.value()); - config_toml.model_provider = Some(config.model_provider_id.clone()); - config_toml.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; - config_toml.model_verbosity = config.model_verbosity; - config_toml.include_permissions_instructions = Some(config.include_permissions_instructions); - config_toml.include_apps_instructions = Some(config.include_apps_instructions); - config_toml.include_environment_context = Some(config.include_environment_context); - config_toml.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); - - // Feature aliases and feature configs need to be written in their resolved - // form; otherwise replay can drift when a legacy key maps to the same - // runtime feature. - let features = config_toml - .features - .get_or_insert_with(FeaturesToml::default); - features.materialize_resolved_enabled(config.features.get()); - let mut multi_agent_v2: MultiAgentV2ConfigToml = - resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; - multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); - features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); - features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { - enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), - path: config.apps_mcp_path_override.clone(), - })); - - config_toml.memories = Some(resolved_config_to_toml::( - &config.memories, - "memories", - )?); - - let agents = config_toml.agents.get_or_insert_with(Default::default); - // Multi-agent v2 owns thread fanout through its feature config. Preserve - // the legacy agents.max_threads setting only when v2 is disabled. - agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { - None - } else { - config.agent_max_threads - }; - agents.max_depth = Some(config.agent_max_depth); - agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; - agents.interrupt_message = Some(config.agent_interrupt_message_enabled); - - config_toml - .skills - .get_or_insert_with(Default::default) - .include_instructions = Some(config.include_skill_instructions); - - Ok(()) -} - -fn render_template( - template: &Template, - interpolation_source: &TomlValue, - field_path: &str, -) -> anyhow::Result { - let variables = - template - .placeholders() - .map(|placeholder| { - let value = lookup_scalar_path(interpolation_source, placeholder).with_context(|| { - format!("failed to render config field `{field_path}` placeholder `{placeholder}`") - })?; - Ok((placeholder.to_string(), value)) - }) - .collect::>>()?; - - template - .render( - variables - .iter() - .map(|(name, value)| (name.as_str(), value.as_str())), - ) - .with_context(|| format!("failed to render config field `{field_path}`")) -} - -fn lookup_scalar_path(value: &TomlValue, path: &str) -> anyhow::Result { - let resolved = value_at_path(value, path) - .with_context(|| format!("template placeholder `{path}` does not exist"))?; - - match resolved { - TomlValue::String(value) => Ok(value.clone()), - TomlValue::Integer(value) => Ok(value.to_string()), - TomlValue::Float(value) => Ok(value.to_string()), - TomlValue::Boolean(value) => Ok(value.to_string()), - _ => bail!( - "template placeholder `{path}` must resolve to a scalar string, integer, float, or boolean" - ), - } -} - -fn value_at_path<'a>(value: &'a TomlValue, path: &str) -> Option<&'a TomlValue> { - let mut current = value; - for segment in path.split('.') { - current = current.as_table()?.get(segment)?; - } - Some(current) -} - -fn value_mut_at_path<'a>(value: &'a mut TomlValue, path: &str) -> Option<&'a mut TomlValue> { - let mut current = value; - let mut segments = path.split('.').peekable(); - - while let Some(segment) = segments.next() { - let table = current.as_table_mut()?; - if segments.peek().is_none() { - return table.get_mut(segment); - } - current = table.get_mut(segment)?; - } - - Some(current) -} - -fn resolved_config_to_toml( - value: &impl serde::Serialize, - label: &'static str, -) -> anyhow::Result -where - Toml: serde::de::DeserializeOwned + serde::Serialize, -{ - crate::config_lock::toml_round_trip(value, label).map_err(anyhow::Error::from) -} diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index f0a8d209d3..d1f190510a 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -1,12 +1,19 @@ use anyhow::Context; use codex_config::config_toml::ConfigLockfileToml; use codex_config::config_toml::ConfigToml; +use codex_config::types::MemoriesToml; +use codex_features::AppsMcpPathOverrideConfigToml; +use codex_features::Feature; +use codex_features::FeatureToml; +use codex_features::FeaturesToml; +use codex_features::MultiAgentV2ConfigToml; use codex_protocol::ThreadId; -use crate::config::template_interpolation::materialized_config_toml; +use crate::config::Config; use crate::config_lock::ConfigLockReplayOptions; use crate::config_lock::clear_config_lock_debug_controls; use crate::config_lock::config_lockfile; +use crate::config_lock::toml_round_trip; use crate::config_lock::validate_config_lock_replay; use super::SessionConfiguration; @@ -74,12 +81,20 @@ fn session_configuration_to_lock_config_toml( sc: &SessionConfiguration, ) -> anyhow::Result { let config = sc.original_config_do_not_use.as_ref(); - let mut lock_config = materialized_config_toml(config)?; + // Start from the resolved layer stack, then patch in values that are only + // known after session setup. Export and replay validation both use this + // path, so every field here is part of the lockfile contract. + let mut lock_config: ConfigToml = config + .config_layer_stack + .effective_config() + .try_into() + .context("failed to deserialize effective config for config lock")?; if config.config_lock_save_fields_resolved_from_model_catalog { save_session_resolved_fields(sc, &mut lock_config); } + save_config_resolved_fields(config, &mut lock_config)?; drop_lockfile_inputs(&mut lock_config); Ok(lock_config) @@ -103,6 +118,64 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.approvals_reviewer = Some(sc.approvals_reviewer); } +/// Saves values stored on `Config` after higher-level resolution, +/// normalization, defaulting, or feature materialization. +/// +/// Persist the resolved representation so replay compares against the behavior +/// Codex actually ran with, not only the user-authored TOML inputs. +fn save_config_resolved_fields( + config: &Config, + lock_config: &mut ConfigToml, +) -> anyhow::Result<()> { + lock_config.web_search = Some(config.web_search_mode.value()); + lock_config.model_provider = Some(config.model_provider_id.clone()); + lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort; + lock_config.model_verbosity = config.model_verbosity; + lock_config.include_permissions_instructions = Some(config.include_permissions_instructions); + lock_config.include_apps_instructions = Some(config.include_apps_instructions); + lock_config.include_environment_context = Some(config.include_environment_context); + lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout); + + // Feature aliases and feature configs need to be written in their resolved + // form; otherwise replay can drift when a legacy key maps to the same + // runtime feature. + let features = lock_config + .features + .get_or_insert_with(FeaturesToml::default); + features.materialize_resolved_enabled(config.features.get()); + let mut multi_agent_v2: MultiAgentV2ConfigToml = + resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; + multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); + features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml { + enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)), + path: config.apps_mcp_path_override.clone(), + })); + lock_config.memories = Some(resolved_config_to_toml::( + &config.memories, + "memories", + )?); + + let agents = lock_config.agents.get_or_insert_with(Default::default); + // Multi-agent v2 owns thread fanout through its feature config. Preserve + // the legacy agents.max_threads setting only when v2 is disabled. + agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) { + None + } else { + config.agent_max_threads + }; + agents.max_depth = Some(config.agent_max_depth); + agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds; + agents.interrupt_message = Some(config.agent_interrupt_message_enabled); + + lock_config + .skills + .get_or_insert_with(Default::default) + .include_instructions = Some(config.include_skill_instructions); + + Ok(()) +} + fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { // The lockfile should contain replayable values, not the profile, // debug-control, file-include, and environment-specific inputs that @@ -122,11 +195,19 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) { lock_config.experimental_use_freeform_apply_patch = None; } +fn resolved_config_to_toml( + value: &impl serde::Serialize, + label: &'static str, +) -> anyhow::Result +where + Toml: serde::de::DeserializeOwned + serde::Serialize, +{ + toml_round_trip(value, label).map_err(anyhow::Error::from) +} + #[cfg(test)] mod tests { use super::*; - use codex_features::FeatureToml; - use codex_features::MultiAgentV2ConfigToml; use pretty_assertions::assert_eq; use std::sync::Arc; From 5d6f23a27bf9c90709af527a7108c1c2eadf5123 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 6 May 2026 13:38:43 +0300 Subject: [PATCH 13/30] Propagate cache key and service tiers in compact (#21249) ## Why `/responses/compact` should preserve the request-affinity fields that apply to the active auth mode. ChatGPT-auth compact requests need the effective `service_tier`, and compact requests for every auth mode need the stable `prompt_cache_key`, so compaction does not quietly lose routing or cache behavior that normal sampling already has. This follows the request-parity direction from #20719, but keeps the net change focused on the compact payload fields needed here. ## What changed - Add `service_tier` and `prompt_cache_key` to the compact endpoint input payload. - Build the remote compact payload from the existing responses request builder output so `Fast` still maps to `priority` when compact sends a service tier. - Pass the turn service tier into remote compaction, but only include it in compact payloads for ChatGPT-backed auth. - Keep `prompt_cache_key` on compact payloads for all auth modes. - Add request-body diff snapshot coverage in `core/tests/suite/compact_remote.rs` for: - API-key auth reusing `prompt_cache_key` while omitting `service_tier` even when `Fast` is configured. - ChatGPT auth reusing both `service_tier` and `prompt_cache_key`. - Drive the snapshot coverage through five varied turns: plain text, multi-part text, tool-call continuation, image+text input, local-shell continuation, and final-turn reasoning output. ## Verification - Added insta snapshots for compact request-body parity against the last normal `/responses` request after five varied turns. - Not run locally per repo guidance; relying on GitHub CI for test execution. --------- Co-authored-by: Codex --- codex-rs/codex-api/src/common.rs | 4 + codex-rs/core/src/client.rs | 21 +- codex-rs/core/src/compact_remote.rs | 8 +- codex-rs/core/tests/common/BUILD.bazel | 3 + .../core/tests/common/context_snapshot.rs | 109 +++++++ codex-rs/core/tests/suite/compact_remote.rs | 279 ++++++++++++++++++ ...pi_auth_prompt_cache_key_request_diff.snap | 43 +++ ...ce_tier_prompt_cache_key_request_diff.snap | 43 +++ 8 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index e2d2ed3c3c..50ac2685b4 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -32,6 +32,10 @@ pub struct CompactionInput<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 00feb81a9f..6449237dd6 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -147,6 +147,12 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); +pub(crate) struct CompactConversationRequestSettings { + pub(crate) effort: Option, + pub(crate) summary: ReasoningSummaryConfig, + pub(crate) service_tier: Option, +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most @@ -414,12 +420,11 @@ impl ModelClient { /// /// The model selection and telemetry context are passed explicitly to keep `ModelClient` /// session-scoped. - pub async fn compact_conversation_history( + pub(crate) async fn compact_conversation_history( &self, prompt: &Prompt, model_info: &ModelInfo, - effort: Option, - summary: ReasoningSummaryConfig, + settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, ) -> Result> { @@ -442,9 +447,9 @@ impl ModelClient { &client_setup.api_provider, prompt, model_info, - effort, - summary, - /*service_tier*/ None, + settings.effort, + settings.summary, + settings.service_tier, )?; let ResponsesApiRequest { model, @@ -453,6 +458,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier, + prompt_cache_key, text, .. } = request; @@ -466,6 +473,8 @@ impl ModelClient { tools, parallel_tool_calls, reasoning, + service_tier: service_tier.as_deref(), + prompt_cache_key: prompt_cache_key.as_deref(), text, }; diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 1a3f0d4a7a..cf30a623b3 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; +use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; @@ -170,8 +171,11 @@ async fn run_remote_compact_task_inner_impl( .compact_conversation_history( &prompt, &turn_context.model_info, - turn_context.reasoning_effort, - turn_context.reasoning_summary, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + service_tier: turn_context.config.service_tier, + }, &turn_context.session_telemetry, &compaction_trace, ) diff --git a/codex-rs/core/tests/common/BUILD.bazel b/codex-rs/core/tests/common/BUILD.bazel index aec0c17817..983a2012b0 100644 --- a/codex-rs/core/tests/common/BUILD.bazel +++ b/codex-rs/core/tests/common/BUILD.bazel @@ -7,4 +7,7 @@ codex_rust_crate( lib_data_extra = [ "//codex-rs/core:model_availability_nux_fixtures", ], + deps_extra = [ + "@crates//:similar", + ], ) diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index cb899969d9..8aaefbbf15 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -1,5 +1,7 @@ use regex_lite::Regex; use serde_json::Value; +use similar::ChangeTag; +use similar::TextDiff; use std::sync::OnceLock; use crate::responses::ResponsesRequest; @@ -242,6 +244,102 @@ pub fn format_labeled_items_snapshot( format!("Scenario: {scenario}\n\n{sections}") } +/// Render changed JSON lines between two captured `/responses` request bodies. +/// +/// Request-parity tests use this to compare the entire JSON payload while showing only fields that +/// changed, with the same redactions as the other context snapshots. +pub fn format_request_body_diff_snapshot( + scenario: &str, + before_title: &str, + before_request: &ResponsesRequest, + after_title: &str, + after_request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let before = format_request_body_snapshot(before_request, options); + let after = format_request_body_snapshot(after_request, options); + let diff = format_changed_lines_diff(before_title, &before, after_title, &after); + format!("Scenario: {scenario}\n\n{diff}") +} + +fn format_request_body_snapshot( + request: &ResponsesRequest, + options: &ContextSnapshotOptions, +) -> String { + let mut body = request.body_json(); + canonicalize_json_snapshot_value(&mut body, options); + serde_json::to_string_pretty(&body).expect("request body should serialize") +} + +fn canonicalize_json_snapshot_value(value: &mut Value, options: &ContextSnapshotOptions) { + match value { + Value::Array(values) => { + for value in values { + canonicalize_json_snapshot_value(value, options); + } + } + Value::Object(map) => { + // Keep request-body snapshots stable when serde_json preserves insertion order. + let mut entries = std::mem::take(map).into_iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + for (key, mut value) in entries { + canonicalize_json_snapshot_value(&mut value, options); + map.insert(key, value); + } + } + Value::String(text) => { + *text = format_snapshot_json_string(text, options); + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } +} + +fn format_snapshot_json_string(text: &str, options: &ContextSnapshotOptions) -> String { + let normalized = match options.render_mode { + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalize_snapshot_uuids( + &normalize_snapshot_line_endings(&canonicalize_snapshot_text(text)), + ), + ContextSnapshotRenderMode::FullText => normalize_snapshot_line_endings(text), + ContextSnapshotRenderMode::KindOnly => unreachable!(), + }; + match options.render_mode { + ContextSnapshotRenderMode::KindWithTextPrefix { max_chars } + if normalized.chars().count() > max_chars => + { + let prefix = normalized.chars().take(max_chars).collect::(); + format!("{prefix}...") + } + ContextSnapshotRenderMode::RedactedText + | ContextSnapshotRenderMode::FullText + | ContextSnapshotRenderMode::KindWithTextPrefix { .. } => normalized, + ContextSnapshotRenderMode::KindOnly => unreachable!(), + } +} + +fn format_changed_lines_diff( + before_title: &str, + before: &str, + after_title: &str, + after: &str, +) -> String { + let mut diff = format!("--- {before_title}\n+++ {after_title}\n"); + for change in TextDiff::from_lines(before, after).iter_all_changes() { + match change.tag() { + ChangeTag::Equal => {} + ChangeTag::Delete => { + diff.push('-'); + diff.push_str(change.value()); + } + ChangeTag::Insert => { + diff.push('+'); + diff.push_str(change.value()); + } + } + } + diff +} + fn format_snapshot_text(text: &str, options: &ContextSnapshotOptions) -> String { match options.render_mode { ContextSnapshotRenderMode::RedactedText => { @@ -342,6 +440,17 @@ fn normalize_dynamic_snapshot_paths(text: &str) -> String { .into_owned() } +fn normalize_snapshot_uuids(text: &str) -> String { + static UUID_RE: OnceLock = OnceLock::new(); + let uuid_re = UUID_RE.get_or_init(|| { + Regex::new( + r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b", + ) + .expect("uuid regex should compile") + }); + uuid_re.replace_all(text, "").into_owned() +} + #[cfg(test)] mod tests { use super::ContextSnapshotOptions; diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index c0a2a89394..5e15b54b77 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -7,6 +7,7 @@ use anyhow::Result; use codex_core::compact::SUMMARY_PREFIX; use codex_features::Feature; use codex_login::CodexAuth; +use codex_protocol::config_types::ServiceTier; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; @@ -105,6 +106,23 @@ fn contains_defer_loading(value: &Value) -> bool { } } +fn canonical_json(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut entries = map.iter().collect::>(); + entries.sort_by(|(left_key, _), (right_key, _)| left_key.cmp(right_key)); + Value::Object( + entries + .into_iter() + .map(|(key, value)| (key.clone(), canonical_json(value))) + .collect(), + ) + } + Value::Array(values) => Value::Array(values.iter().map(canonical_json).collect()), + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => value.clone(), + } +} + const PRETURN_CONTEXT_DIFF_CWD: &str = "/tmp/PRETURN_CONTEXT_DIFF_CWD"; const DUMMY_FUNCTION_NAME: &str = "test_tool"; const REMOTE_COMPACT_TURN_COMPLETE_TIMEOUT: Duration = Duration::from_secs(30); @@ -417,6 +435,267 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { Ok(()) } +async fn assert_remote_manual_compact_request_parity( + auth: CodexAuth, + configured_service_tier: Option, + expected_service_tier: Option<&str>, + snapshot_name: &str, + scenario: &str, +) -> Result<()> { + let mut builder = test_codex().with_auth(auth); + if let Some(service_tier) = configured_service_tier { + builder = builder.with_config(move |config| { + config.service_tier = Some(service_tier); + }); + } + let harness = TestCodexHarness::with_builder(builder).await?; + let codex = harness.test().codex.clone(); + let image_url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + .to_string(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("turn-one-assistant", "TURN_ONE_ASSISTANT"), + responses::ev_completed("turn-one-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-two-reasoning", + &["TURN_TWO_REASONING"], + &["turn two raw content"], + ), + responses::ev_assistant_message("turn-two-assistant", "TURN_TWO_ASSISTANT"), + responses::ev_completed("turn-two-response"), + ]), + responses::sse(vec![ + responses::ev_function_call("turn-three-call", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed("turn-three-call-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-three-assistant", "TURN_THREE_ASSISTANT"), + responses::ev_completed("turn-three-final-response"), + ]), + responses::sse(vec![ + responses::ev_local_shell_call( + "turn-four-local-shell", + "completed", + vec!["/bin/echo", "TURN_FOUR_LOCAL_SHELL"], + ), + responses::ev_completed("turn-four-local-shell-response"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("turn-four-assistant", "TURN_FOUR_ASSISTANT"), + responses::ev_completed("turn-four-final-response"), + ]), + responses::sse(vec![ + responses::ev_reasoning_item( + "turn-five-reasoning", + &["TURN_FIVE_REASONING"], + &["turn five raw content"], + ), + responses::ev_assistant_message("turn-five-assistant", "TURN_FIVE_ASSISTANT"), + responses::ev_completed("turn-five-response"), + ]), + ], + ) + .await; + let compact_mock = responses::mount_compact_user_history_with_summary_once( + harness.server(), + "REMOTE_CACHE_TIER_SUMMARY", + ) + .await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_ONE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Text { + text: "TURN_TWO_PREFIX".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "TURN_TWO_SUFFIX".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_THREE_TOOL_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![ + UserInput::Image { image_url }, + UserInput::Text { + text: "TURN_FOUR_IMAGE_USER".to_string(), + text_elements: Vec::new(), + }, + ], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "TURN_FIVE_USER".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await?; + wait_for_turn_complete(&codex).await; + + codex.submit(Op::Compact).await?; + wait_for_turn_complete(&codex).await; + + let response_requests = responses_mock.requests(); + assert_eq!( + response_requests.len(), + 7, + "expected five turns with one unsupported tool continuation and one local shell continuation" + ); + assert_eq!( + compact_mock.requests().len(), + 1, + "expected exactly one remote compact request" + ); + let normal_request = response_requests + .last() + .cloned() + .expect("last turn request missing"); + let compact_request = compact_mock.single_request(); + let normal_body = normal_request.body_json(); + let compact_body = compact_request.body_json(); + + let mut expected_compact_body_without_input = normal_body.clone(); + let expected_compact_object = expected_compact_body_without_input + .as_object_mut() + .expect("responses request body should be an object"); + for field in [ + "input", + "client_metadata", + "include", + "store", + "stream", + "tool_choice", + ] { + expected_compact_object.remove(field); + } + if expected_service_tier.is_none() { + expected_compact_object.remove("service_tier"); + } + let mut compact_body_without_input = compact_body.clone(); + compact_body_without_input + .as_object_mut() + .expect("compact request body should be an object") + .remove("input"); + let canonical_compact_body_without_input = canonical_json(&compact_body_without_input); + let canonical_expected_compact_body_without_input = + canonical_json(&expected_compact_body_without_input); + + assert_eq!( + json!({ + "compact_body_without_input": canonical_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": compact_body["prompt_cache_key"] == normal_body["prompt_cache_key"], + "prompt_cache_key_present": compact_body["prompt_cache_key"].is_string(), + "service_tier": compact_body.get("service_tier").and_then(Value::as_str), + }), + json!({ + "compact_body_without_input": canonical_expected_compact_body_without_input, + "expected_compact_body_without_input": canonical_expected_compact_body_without_input, + "prompt_cache_key_matches_responses": true, + "prompt_cache_key_present": true, + "service_tier": expected_service_tier, + }), + "compact requests should carry the same shared request fields as /responses" + ); + + insta::assert_snapshot!( + snapshot_name, + context_snapshot::format_request_body_diff_snapshot( + scenario, + "Last Normal /responses Request", + &normal_request, + "Remote /responses/compact Request", + &compact_request, + &ContextSnapshotOptions::default(), + ) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_api_auth_reuses_prompt_cache_key() -> Result<()> { + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::from_api_key("dummy"), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_api_auth_prompt_cache_key_request_diff", + "After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_manual_compact_chatgpt_auth_reuses_service_tier_and_prompt_cache_key() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + assert_remote_manual_compact_request_parity( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + Some(ServiceTier::Fast), + Some("priority"), + "remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff", + "After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields.", + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..4527899106 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied API-key-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap new file mode 100644 index 0000000000..40baae2674 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -0,0 +1,43 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "context_snapshot::format_request_body_diff_snapshot(scenario,\n\"Last Normal /responses Request\", &normal_request,\n\"Remote /responses/compact Request\", &compact_request,\n&ContextSnapshotOptions::default(),)" +--- +Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses the normal responses service_tier and prompt_cache_key while omitting responses-only fields. + +--- Last Normal /responses Request ++++ Remote /responses/compact Request +- "client_metadata": { +- "x-codex-installation-id": "" +- }, +- "include": [ +- "reasoning.encrypted_content" +- ], ++ }, ++ { ++ "content": [ ++ { ++ "text": "turn five raw content", ++ "type": "reasoning_text" ++ } ++ ], ++ "encrypted_content": "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYnR1cm4gZml2ZSByYXcgY29udGVudA==", ++ "summary": [ ++ { ++ "text": "TURN_FIVE_REASONING", ++ "type": "summary_text" ++ } ++ ], ++ "type": "reasoning" ++ }, ++ { ++ "content": [ ++ { ++ "text": "TURN_FIVE_ASSISTANT", ++ "type": "output_text" ++ } ++ ], ++ "role": "assistant", ++ "type": "message" +- "store": false, +- "stream": true, +- "tool_choice": "auto", From 8f3bb355f432317e8fdef3b6a5f9ef014c575fa1 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 12:48:54 +0200 Subject: [PATCH 14/30] Move installation ID resolution out of core startup (#21182) ## Summary - resolve or inject the installation ID before core startup and pass it through `ThreadManager`, `CodexSpawnArgs`, and `Session` as a plain `String` - keep child sessions on the parent installation ID instead of rediscovering it inside core - propagate installation ID startup failures in `mcp-server` instead of panicking ## Why Core was still touching the filesystem on the session startup path to discover `installation_id`. This moves that work to the outer host boundary so core no longer depends on `codex_home` reads during session construction. --------- Co-authored-by: Codex --- codex-rs/app-server/src/in_process.rs | 11 ++-- codex-rs/app-server/src/lib.rs | 3 ++ codex-rs/app-server/src/mcp_refresh.rs | 1 + codex-rs/app-server/src/message_processor.rs | 3 ++ .../src/message_processor_tracing_tests.rs | 1 + codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/prompt_debug.rs | 3 ++ codex-rs/core/src/session/mod.rs | 4 +- codex-rs/core/src/session/session.rs | 6 ++- codex-rs/core/src/session/tests.rs | 5 ++ .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/thread_manager.rs | 30 +++++++++-- codex-rs/core/src/thread_manager_tests.rs | 52 +++++++++++++++++++ .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 3 ++ codex-rs/core/tests/suite/client.rs | 5 ++ codex-rs/mcp-server/src/lib.rs | 3 ++ codex-rs/mcp-server/src/message_processor.rs | 2 + codex-rs/thread-manager-sample/src/main.rs | 3 ++ 20 files changed, 128 insertions(+), 11 deletions(-) diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 73cfe75a54..35b92548b8 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -83,6 +83,7 @@ use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; @@ -345,7 +346,7 @@ impl InProcessClientHandle { /// the runtime is shut down and an `InvalidData` error is returned. pub async fn start(args: InProcessStartArgs) -> IoResult { let initialize = args.initialize.clone(); - let client = start_uninitialized(args).await; + let client = start_uninitialized(args).await?; let initialize_response = client .request(ClientRequest::Initialize { @@ -365,12 +366,13 @@ pub async fn start(args: InProcessStartArgs) -> IoResult Ok(client) } -async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { +async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); let state_db = match args.state_db.clone() { Some(state_db) => Some(state_db), None => init_state_db_from_config(args.config.as_ref()).await, }; + let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -438,6 +440,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, + installation_id, rpc_transport: AppServerRpcTransport::InProcess, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, @@ -718,13 +721,13 @@ async fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle } }); - InProcessClientHandle { + Ok(InProcessClientHandle { client: InProcessClientSender { client_tx }, event_rx, runtime_handle, #[cfg(test)] _test_codex_home: None, - } + }) } #[cfg(test)] diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index cf6a9e890e..c7efae69c6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -7,6 +7,7 @@ use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; @@ -621,6 +622,7 @@ pub async fn run_main_with_transport_options( None => error!("{}", warning.summary), } } + let installation_id = resolve_installation_id(&config.codex_home).await?; let transport_shutdown_token = CancellationToken::new(); let mut transport_accept_handles = Vec::>::new(); @@ -764,6 +766,7 @@ pub async fn run_main_with_transport_options( config_warnings, session_source, auth_manager, + installation_id, rpc_transport: analytics_rpc_transport(&transport), remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 93dc02f7c3..b1471d88fc 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -189,6 +189,7 @@ mod tests { state_db, thread_store, agent_graph_store, + "11111111-1111-4111-8111-111111111111".to_string(), )); thread_manager.start_thread(good_config).await?; thread_manager.start_thread(bad_config).await?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e0cc3bd176..a3c3877fdc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -259,6 +259,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, + pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, @@ -281,6 +282,7 @@ impl MessageProcessor { config_warnings, session_source, auth_manager, + installation_id, rpc_transport, remote_control_handle, plugin_startup_tasks, @@ -302,6 +304,7 @@ impl MessageProcessor { state_db.clone(), Arc::clone(&thread_store), agent_graph_store.clone(), + installation_id, )); thread_manager .plugins_manager() diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 67b668bafc..27e2c2f473 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -298,6 +298,7 @@ async fn build_test_processor( config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 13c87df9e0..9af459830a 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -42,6 +42,7 @@ pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; pub use codex_core::init_state_db; pub use codex_core::init_state_db_from_config; +pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 572cffcabe..3ead350dfa 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -76,6 +76,7 @@ pub(crate) async fn run_codex_thread_interactive( let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index a40fd27942..1b0c75230c 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -13,6 +13,7 @@ use codex_protocol::user_input::UserInput; use tokio_util::sync::CancellationToken; use crate::config::Config; +use crate::resolve_installation_id; use crate::session::session::Session; use crate::session::turn::build_prompt; use crate::session::turn::built_tools; @@ -42,6 +43,7 @@ pub async fn build_prompt_input( .ok_or_else(|| std::io::Error::other("prompt debug requires state db"))?; let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, Arc::clone(&auth_manager), @@ -51,6 +53,7 @@ pub async fn build_prompt_input( state_db, thread_store, agent_graph_store, + installation_id, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 518dacde58..9d83ec2ff0 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,7 +32,6 @@ use crate::context::PersonalitySpecInstructions; use crate::default_skill_metadata_budget; use crate::environment_selection::ResolvedTurnEnvironments; use crate::exec_policy::ExecPolicyManager; -use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; @@ -384,6 +383,7 @@ pub struct CodexSpawnOk { pub(crate) struct CodexSpawnArgs { pub(crate) config: Config, + pub(crate) installation_id: String, pub(crate) auth_manager: Arc, pub(crate) models_manager: SharedModelsManager, pub(crate) environment_manager: Arc, @@ -447,6 +447,7 @@ impl Codex { async fn spawn_internal(args: CodexSpawnArgs) -> CodexResult { let CodexSpawnArgs { mut config, + installation_id, auth_manager, models_manager, environment_manager, @@ -630,6 +631,7 @@ impl Codex { let session = Session::new( session_configuration, config.clone(), + installation_id, auth_manager.clone(), models_manager.clone(), exec_policy, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 46f25cd1f0..124734a254 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -12,6 +12,7 @@ use tokio::sync::Semaphore; /// A session has at most 1 running task at a time, and can be interrupted by user input. pub(crate) struct Session { pub(crate) conversation_id: ThreadId, + pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, pub(super) out_of_band_elicitation_paused: watch::Sender, @@ -344,6 +345,7 @@ impl Session { pub(crate) async fn new( mut session_configuration: SessionConfiguration, config: Arc, + installation_id: String, auth_manager: Arc, models_manager: SharedModelsManager, exec_policy: Arc, @@ -789,7 +791,6 @@ impl Session { }); } - let installation_id = resolve_installation_id(&config.codex_home).await?; let analytics_events_client = analytics_events_client.unwrap_or_else(|| { AnalyticsEventsClient::new( Arc::clone(&auth_manager), @@ -849,7 +850,7 @@ impl Session { Some(Arc::clone(&auth_manager)), session_id, thread_id, - installation_id, + installation_id.clone(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -869,6 +870,7 @@ impl Session { let (mailbox, mailbox_rx) = Mailbox::new(); let sess = Arc::new(Session { conversation_id: thread_id, + installation_id, tx_event: tx_event.clone(), agent_status, out_of_band_elicitation_paused, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 26dca0c25f..a20f5489c6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3566,6 +3566,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let result = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -3799,6 +3800,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Session { conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, @@ -3905,6 +3907,7 @@ async fn make_session_with_config_and_rx( let session = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -4012,6 +4015,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( let session = Session::new( session_configuration, Arc::clone(&config), + "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, Arc::new(ExecPolicyManager::default()), @@ -5482,6 +5486,7 @@ where let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); let session = Arc::new(Session { conversation_id: thread_id, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, out_of_band_elicitation_paused: watch::channel(false).0, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 8f8577e7fe..1026468627 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -741,6 +741,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { let CodexSpawnOk { codex, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), auth_manager, models_manager, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 124f0729ef..331ed3ca15 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -7,6 +7,7 @@ use crate::environment_selection::default_thread_environment_selections; use crate::environment_selection::resolve_environment_selections; use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; +use crate::resolve_installation_id; use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::session::Codex; @@ -253,6 +254,7 @@ pub(crate) struct ThreadManagerState { state_db: StateDbHandle, agent_graph_store: Arc, session_source: SessionSource, + installation_id: String, analytics_events_client: Option, // Captures submitted ops for testing purpose when test mode is enabled. ops_log: Option, @@ -316,6 +318,7 @@ impl ThreadManager { state_db: StateDbHandle, thread_store: Arc, agent_graph_store: Arc, + installation_id: String, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -346,6 +349,7 @@ impl ThreadManager { agent_graph_store, auth_manager, session_source, + installation_id, analytics_events_client, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), @@ -373,12 +377,21 @@ impl ThreadManager { OPENAI_PROVIDER_ID.to_string(), ) .await; + let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { + Ok(codex_home) => codex_home, + Err(err) => panic!("test codex_home should be absolute: {err}"), + }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); let mut manager = Self::with_models_provider_and_home_and_state_db_for_tests( auth, provider, codex_home.clone(), Arc::new(EnvironmentManager::default_for_tests()), state_db, + skills_codex_home, + installation_id, ); manager._test_codex_home_guard = Some(TempCodexHomeGuard { path: codex_home }); manager @@ -398,12 +411,21 @@ impl ThreadManager { OPENAI_PROVIDER_ID.to_string(), ) .await; + let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { + Ok(codex_home) => codex_home, + Err(err) => panic!("test codex_home should be absolute: {err}"), + }; + let installation_id = resolve_installation_id(&skills_codex_home) + .await + .unwrap_or_else(|err| panic!("resolve test installation id failed: {err}")); Self::with_models_provider_and_home_and_state_db_for_tests( auth, provider, codex_home, environment_manager, state_db, + skills_codex_home, + installation_id, ) } @@ -413,13 +435,11 @@ impl ThreadManager { codex_home: PathBuf, environment_manager: Arc, state_db: StateDbHandle, + skills_codex_home: AbsolutePathBuf, + installation_id: String, ) -> Self { set_thread_manager_test_mode_for_tests(/*enabled*/ true); let auth_manager = AuthManager::from_auth_for_testing(auth); - let skills_codex_home = match AbsolutePathBuf::from_absolute_path_checked(&codex_home) { - Ok(codex_home) => codex_home, - Err(err) => panic!("test codex_home should be absolute: {err}"), - }; let (thread_created_tx, _) = broadcast::channel(THREAD_CREATED_CHANNEL_CAPACITY); let restriction_product = SessionSource::Exec.restriction_product(); let plugins_manager = Arc::new(PluginsManager::new_with_restriction_product( @@ -459,6 +479,7 @@ impl ThreadManager { agent_graph_store, auth_manager, session_source: SessionSource::Exec, + installation_id, analytics_events_client: None, ops_log: should_use_test_thread_manager_behavior() .then(|| Arc::new(std::sync::Mutex::new(Vec::new()))), @@ -1199,6 +1220,7 @@ impl ThreadManagerState { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { config, + installation_id: self.installation_id.clone(), auth_manager, models_manager: Arc::clone(&self.models_manager), environment_manager: Arc::clone(&self.environment_manager), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index a61a3a3545..0f6afa05a6 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::config::test_config; +use crate::installation_id::INSTALLATION_ID_FILENAME; use crate::rollout::RolloutRecorder; use crate::session::session::SessionSettingsUpdate; use crate::session::tests::make_session_and_context; @@ -26,6 +27,8 @@ use std::time::Duration; use tempfile::tempdir; use wiremock::MockServer; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, @@ -415,6 +418,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -509,6 +513,46 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { ); } +#[tokio::test] +async fn explicit_installation_id_skips_codex_home_file() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let installation_id = uuid::Uuid::new_v4().to_string(); + let (state_db, thread_store, agent_graph_store) = state_backed_stores(&config).await; + let manager = ThreadManager::new( + &config, + auth_manager, + SessionSource::Exec, + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*analytics_events_client*/ None, + state_db, + thread_store, + agent_graph_store, + installation_id.clone(), + ); + + let thread = manager + .start_thread(config.clone()) + .await + .expect("start thread with explicit installation id"); + + assert!(!config.codex_home.join(INSTALLATION_ID_FILENAME).exists()); + assert_eq!(thread.thread.codex.session.installation_id, installation_id); + + thread + .thread + .shutdown_and_wait() + .await + .expect("shutdown thread"); + let _ = manager.remove_thread(&thread.thread_id).await; +} + #[tokio::test] async fn resume_active_thread_from_rollout_returns_running_thread() { let temp_dir = tempdir().expect("tempdir"); @@ -529,6 +573,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -585,6 +630,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -646,6 +692,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -731,6 +778,7 @@ async fn new_uses_active_provider_for_model_refresh() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -945,6 +993,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1051,6 +1100,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1146,6 +1196,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager @@ -1287,6 +1338,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { state_db, thread_store, agent_graph_store, + TEST_INSTALLATION_ID.to_string(), ); let source = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 9a23550d41..b678f3ffe8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3171,6 +3171,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr state_db.clone(), thread_store_from_config(&config, state_db.clone()), agent_graph_store_from_state_db(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), ); let parent = manager diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 13dd026654..ac118615bd 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -18,6 +18,7 @@ use codex_core::ThreadManager; use codex_core::agent_graph_store_from_state_db; use codex_core::config::Config; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::shell::Shell; use codex_core::shell::get_shell_by_model_provided_path; use codex_core::thread_store_from_config; @@ -431,6 +432,7 @@ impl TestCodexBuilder { .expect("test codex requires state db"); let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home).await?; ThreadManager::new( &config, codex_core::test_support::auth_manager_from_auth(auth.clone()), @@ -440,6 +442,7 @@ impl TestCodexBuilder { state_db, thread_store, agent_graph_store, + installation_id, ) } else { codex_core::test_support::thread_manager_with_models_provider_and_home( diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 6f1ab3d672..143681e2e1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -7,6 +7,7 @@ use codex_core::ResponseEvent; use codex_core::ThreadManager; use codex_core::agent_graph_store_from_state_db; use codex_core::init_state_db_from_config; +use codex_core::resolve_installation_id; use codex_core::thread_store_from_config; use codex_features::Feature; use codex_login::AuthManager; @@ -1120,6 +1121,9 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { .expect("client test requires state db"); let thread_store = thread_store_from_config(&config, state_db.clone()); let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); + let installation_id = resolve_installation_id(&config.codex_home) + .await + .expect("resolve installation id"); let thread_manager = ThreadManager::new( &config, auth_manager, @@ -1129,6 +1133,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { state_db, thread_store, agent_graph_store, + installation_id, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index ac764456f5..2fd94e586a 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; @@ -112,6 +113,7 @@ pub async fn run_main( // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + let installation_id = resolve_installation_id(&config.codex_home).await?; // Task: read from stdin, push to `incoming_tx`. let stdin_reader_handle = tokio::spawn({ @@ -144,6 +146,7 @@ pub async fn run_main( arg0_paths, Arc::new(config), environment_manager, + installation_id, ) .await; async move { diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 2591891e4d..73cdb6193b 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -55,6 +55,7 @@ impl MessageProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, environment_manager: Arc, + installation_id: String, ) -> Option { let outgoing = Arc::new(outgoing); let auth_manager = AuthManager::shared_from_config( @@ -74,6 +75,7 @@ impl MessageProcessor { state_db, thread_store, agent_graph_store, + installation_id, )); Some(Self { outgoing, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index a27c78d543..4ad937f9af 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -58,6 +58,7 @@ use codex_core_api::built_in_model_providers; use codex_core_api::find_codex_home; use codex_core_api::init_state_db_from_config; use codex_core_api::item_event_to_server_notification; +use codex_core_api::resolve_installation_id; use codex_core_api::set_default_originator; use codex_core_api::thread_store_from_config; @@ -119,6 +120,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let agent_graph_store = agent_graph_store_from_state_db(state_db.clone()); let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await); + let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, auth_manager, @@ -128,6 +130,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { state_db, Arc::clone(&thread_store), agent_graph_store, + installation_id, ); let NewThread { From ca257b6ce5db5c2710ec8da290b25b263154e402 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 15:05:54 +0200 Subject: [PATCH 15/30] chore: spawn MCP for memories (#21214) Co-authored-by: Codex --- codex-rs/Cargo.lock | 13 ++ codex-rs/Cargo.toml | 2 + codex-rs/builtin-mcps/BUILD.bazel | 6 + codex-rs/builtin-mcps/Cargo.toml | 21 +++ codex-rs/builtin-mcps/src/lib.rs | 132 +++++++++++++++++ codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 20 +++ codex-rs/codex-mcp/Cargo.toml | 1 + codex-rs/codex-mcp/src/lib.rs | 3 + codex-rs/codex-mcp/src/mcp/mod.rs | 6 +- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/config/config_tests.rs | 175 ++++++++++++++++++++++- codex-rs/core/src/config/mod.rs | 14 +- codex-rs/features/src/lib.rs | 8 ++ codex-rs/features/src/tests.rs | 7 + codex-rs/memories/README.md | 2 + 16 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 codex-rs/builtin-mcps/BUILD.bazel create mode 100644 codex-rs/builtin-mcps/Cargo.toml create mode 100644 codex-rs/builtin-mcps/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b0d9cfe527..6359089371 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2126,6 +2126,17 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-builtin-mcps" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-config", + "codex-memories-mcp", + "codex-utils-absolute-path", + "pretty_assertions", +] + [[package]] name = "codex-bwrap" version = "0.0.0" @@ -2171,6 +2182,7 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", + "codex-builtin-mcps", "codex-chatgpt", "codex-cloud-tasks", "codex-config", @@ -2998,6 +3010,7 @@ dependencies = [ "async-channel", "codex-api", "codex-async-utils", + "codex-builtin-mcps", "codex-config", "codex-exec-server", "codex-login", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index a58655ed20..ba20cfb906 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,6 +5,7 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "builtin-mcps", "bwrap", "ansi-escape", "async-utils", @@ -138,6 +139,7 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } +codex-builtin-mcps = { path = "builtin-mcps" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } diff --git a/codex-rs/builtin-mcps/BUILD.bazel b/codex-rs/builtin-mcps/BUILD.bazel new file mode 100644 index 0000000000..9c738d636b --- /dev/null +++ b/codex-rs/builtin-mcps/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "builtin-mcps", + crate_name = "codex_builtin_mcps", +) diff --git a/codex-rs/builtin-mcps/Cargo.toml b/codex-rs/builtin-mcps/Cargo.toml new file mode 100644 index 0000000000..de64ec7033 --- /dev/null +++ b/codex-rs/builtin-mcps/Cargo.toml @@ -0,0 +1,21 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-builtin-mcps" +version.workspace = true + +[lib] +name = "codex_builtin_mcps" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-config = { workspace = true } +codex-memories-mcp = { workspace = true } +codex-utils-absolute-path = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/builtin-mcps/src/lib.rs b/codex-rs/builtin-mcps/src/lib.rs new file mode 100644 index 0000000000..3fd4483dc1 --- /dev/null +++ b/codex-rs/builtin-mcps/src/lib.rs @@ -0,0 +1,132 @@ +//! Built-in MCP servers shipped with Codex. +//! +//! Built-ins use the same stdio MCP path as user-configured servers, but are +//! declared here so product-owned MCPs do not need to live in `codex-core`. + +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::path::Path; + +pub const MEMORIES_MCP_SERVER_NAME: &str = "memories"; +const BUILTIN_MCP_SUBCOMMAND: &str = "builtin-mcp"; + +#[derive(Debug, Clone, Copy)] +pub struct BuiltinMcpServerOptions<'a> { + pub codex_self_exe: Option<&'a Path>, + pub codex_home: &'a Path, + pub memories_enabled: bool, +} + +pub fn configured_builtin_mcp_servers( + options: BuiltinMcpServerOptions<'_>, +) -> HashMap { + let Some(codex_self_exe) = options.codex_self_exe else { + return HashMap::new(); + }; + + let mut servers = HashMap::new(); + if options.memories_enabled { + servers.insert( + MEMORIES_MCP_SERVER_NAME.to_string(), + builtin_stdio_server_config( + codex_self_exe, + options.codex_home, + MEMORIES_MCP_SERVER_NAME, + ), + ); + } + servers +} + +pub async fn run_builtin_mcp_server( + name: &str, + codex_home: &AbsolutePathBuf, +) -> anyhow::Result<()> { + match name { + MEMORIES_MCP_SERVER_NAME => codex_memories_mcp::run_stdio_server(codex_home).await, + _ => anyhow::bail!("unknown built-in MCP server: {name}"), + } +} + +fn builtin_stdio_server_config( + codex_self_exe: &Path, + codex_home: &Path, + name: &str, +) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: codex_self_exe.to_string_lossy().into_owned(), + args: vec![ + BUILTIN_MCP_SUBCOMMAND.to_string(), + name.to_string(), + "--codex-home".to_string(), + codex_home.to_string_lossy().into_owned(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }, + experimental_environment: None, + enabled: true, + required: false, + supports_parallel_tool_calls: true, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth_resource: None, + tools: HashMap::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn configured_builtin_mcp_servers_adds_memories_when_enabled() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: Some(Path::new("/tmp/codex")), + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + let server = servers + .get(MEMORIES_MCP_SERVER_NAME) + .expect("memories server should exist"); + assert_eq!( + server.transport, + McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + "/tmp/codex-home".to_string(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + } + ); + } + + #[test] + fn configured_builtin_mcp_servers_requires_reexec_path() { + let codex_home = AbsolutePathBuf::try_from("/tmp/codex-home").expect("absolute codex home"); + let servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: None, + codex_home: codex_home.as_path(), + memories_enabled: true, + }); + + assert!(servers.is_empty()); + } +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index cdee241b42..b644734f4c 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,6 +24,7 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5a8ffab36a..f3545629c1 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -123,6 +123,10 @@ enum Subcommand { /// Start Codex as an MCP server (stdio). McpServer, + /// Internal: start a Codex-shipped MCP server (stdio). + #[clap(hide = true, name = "builtin-mcp")] + BuiltinMcp(BuiltinMcpCommand), + /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), @@ -175,6 +179,13 @@ enum Subcommand { Features(FeaturesCli), } +#[derive(Debug, Args)] +struct BuiltinMcpCommand { + name: String, + #[arg(long)] + codex_home: PathBuf, +} + #[derive(Debug, Parser)] #[command(bin_name = "codex plugin")] struct PluginCli { @@ -809,6 +820,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } + Some(Subcommand::BuiltinMcp(command)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "builtin-mcp", + )?; + let codex_home = AbsolutePathBuf::try_from(command.codex_home)?; + codex_builtin_mcps::run_builtin_mcp_server(&command.name, &codex_home).await?; + } Some(Subcommand::Mcp(mut mcp_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index c3061adca9..c47bd947e6 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -16,6 +16,7 @@ anyhow = { workspace = true } async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-api = { workspace = true } +codex-builtin-mcps = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9b11cbaec1..54631cd1c1 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -19,6 +19,9 @@ pub use auth_elicitation::build_auth_elicitation_plan; pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; +pub use codex_builtin_mcps::BuiltinMcpServerOptions; +pub use codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME; +pub use codex_builtin_mcps::configured_builtin_mcp_servers; pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 2fdbb7ccd4..958677b9e8 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -129,7 +129,11 @@ pub struct McpConfig { /// ChatGPT auth is checked separately at runtime before the built-in apps /// MCP server is added. pub apps_enabled: bool, - /// User-configured and plugin-provided MCP servers keyed by server name. + /// Configured MCP servers keyed by server name. + /// + /// This includes product-owned built-ins, user-configured servers, and + /// plugin-provided servers. Runtime-only additions belong in + /// [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 57d3d276b2..9f74b8b7cf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -379,6 +379,9 @@ "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, @@ -3931,6 +3934,9 @@ "browser_use_external": { "type": "boolean" }, + "builtin_mcp": { + "type": "boolean" + }, "child_agents_md": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7001f9015c..f2ae82810a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -195,12 +195,13 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { "\n global instructions \n", )?; - let config = Config::load_from_base_config_with_overrides( + let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; + let _ = config.features.enable(Feature::MemoryTool); assert_eq!( config.user_instructions.as_deref(), @@ -3344,6 +3345,77 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn to_mcp_config_empty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::new()), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_nonempty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let requirements = codex_config::ConfigRequirementsToml { + mcp_servers: Some(BTreeMap::from([( + "docs".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "docs-mcp".to_string(), + }, + }, + )])), + ..Default::default() + }; + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + config.codex_self_exe = Some(PathBuf::from("/tmp/codex")); + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| (server.enabled, server.disabled_reason.clone())), + Some((true, None)) + ); + + Ok(()) +} + #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4166,6 +4238,107 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(&McpServerTransportConfig::Stdio { + command: "/tmp/codex".to_string(), + args: vec![ + "builtin-mcp".to_string(), + "memories".to_string(), + "--codex-home".to_string(), + codex_home.path().display().to_string(), + ], + env: None, + env_vars: Vec::new(), + cwd: None, + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_builtin_mcps_when_feature_is_disabled() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!( + !mcp_config + .configured_mcp_servers + .contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_reserves_enabled_builtin_mcp_names() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml { + mcp_servers: HashMap::from([( + codex_mcp::MEMORIES_MCP_SERVER_NAME.to_string(), + http_mcp("https://user.example/memories"), + )]), + ..ConfigToml::default() + }, + ConfigOverrides { + codex_self_exe: Some(PathBuf::from("/tmp/codex")), + ..ConfigOverrides::default() + }, + codex_home.abs(), + ) + .await?; + let _ = config.features.enable(Feature::BuiltInMcp); + let _ = config.features.enable(Feature::MemoryTool); + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert!(matches!( + mcp_config + .configured_mcp_servers + .get(codex_mcp::MEMORIES_MCP_SERVER_NAME) + .map(|server| &server.transport), + Some(McpServerTransportConfig::Stdio { .. }) + )); + + Ok(()) +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 43d88bcdd2..d868e8474c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -70,7 +70,9 @@ use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; +use codex_mcp::BuiltinMcpServerOptions; use codex_mcp::McpConfig; +use codex_mcp::configured_builtin_mcp_servers; use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -1087,6 +1089,13 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; + let builtin_mcp_servers = configured_builtin_mcp_servers(BuiltinMcpServerOptions { + codex_self_exe: self.codex_self_exe.as_deref(), + codex_home: self.codex_home.as_path(), + memories_enabled: self.features.enabled(Feature::BuiltInMcp) + && self.features.enabled(Feature::MemoryTool) + && self.memories.use_memories, + }); let mut configured_mcp_servers = self.mcp_servers.get().clone(); for plugin in loaded_plugins .plugins() @@ -1106,9 +1115,12 @@ impl Config { if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() && mcp_requirements.value.is_empty() { - // A present empty allowlist bans all MCPs, including plugin MCPs merged above. + // A present empty allowlist bans configurable MCPs, including plugin MCPs merged + // above. Built-ins are product-owned and stay available regardless of admin + // allowlists. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + configured_mcp_servers.extend(builtin_mcp_servers); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index dfacc0d55b..19605cd40b 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -134,6 +134,8 @@ pub enum Feature { Sqlite, /// Enable startup memory extraction and file-backed memory consolidation. MemoryTool, + /// Enable product-owned built-in MCP servers. + BuiltInMcp, /// Enable the Chronicle sidecar for passive screen-context memories. Chronicle, /// Append additional AGENTS.md guidance to user instructions. @@ -793,6 +795,12 @@ pub const FEATURES: &[FeatureSpec] = &[ }, default_enabled: false, }, + FeatureSpec { + id: Feature::BuiltInMcp, + key: "builtin_mcp", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::Chronicle, key: "chronicle", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 4a258141b5..4ba6b76ce8 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -129,6 +129,13 @@ fn remote_compaction_v2_is_under_development() { ); } +#[test] +fn builtin_mcp_is_under_development() { + assert_eq!(Feature::BuiltInMcp.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::BuiltInMcp.default_enabled(), false); + assert_eq!(feature_for_key("builtin_mcp"), Some(Feature::BuiltInMcp)); +} + #[test] fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { assert_eq!( diff --git a/codex-rs/memories/README.md b/codex-rs/memories/README.md index 9195e89ada..73aefde297 100644 --- a/codex-rs/memories/README.md +++ b/codex-rs/memories/README.md @@ -10,6 +10,8 @@ Runtime orchestration for Phase 1 and Phase 2 still lives in `codex-core` under - `codex-rs/memories/read` (`codex-memories-read`) owns the read path: memory developer-instruction injection, memory citation parsing, and read-usage telemetry classification. +- `codex-rs/memories/mcp` (`codex-memories-mcp`) exposes the read-only memory + filesystem through the built-in MCP surface. - `codex-rs/memories/write` (`codex-memories-write`) owns the write path: Phase 1 and Phase 2 prompt rendering, filesystem artifact helpers, workspace diff helpers, and extension resource pruning. From 5ecff051962e7299c743e4ce9c1545d71b756924 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 15:23:25 +0200 Subject: [PATCH 16/30] feat(app-server): move v2 `sessionId` onto `Thread` (#21336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why `session_id` and `thread_id` are separate identities after #20437, but app-server only surfaced `sessionId` on the `thread/start`, `thread/resume`, and `thread/fork` response envelopes. Other thread-bearing surfaces such as `thread/list`, `thread/read`, `thread/started`, `thread/rollback`, `thread/metadata/update`, and `thread/unarchive` either lacked the grouping key or forced clients to special-case those three responses. Making `sessionId` part of the reusable `Thread` payload gives every v2 API surface one place to expose session-tree identity. ## Mental model 1. thread.sessionId lives on `Thread` 2. It is a view/runtime identity for the current live session tree, not durable stored lineage metadata 3. When app-server has a live loaded thread, it copies the real value from core’s session_configured.session_id 4. When it only has stored/unloaded data, it falls back to thread.sessionId = thread.id ## What changed - Added `sessionId` to the v2 [`Thread`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs#L105-L109). - Removed the duplicate top-level `sessionId` fields from `thread/start`, `thread/resume`, and `thread/fork`; clients should now read `response.thread.sessionId`. - Populated `thread.sessionId` when building live thread responses, replaying loaded threads, and returning stored-thread summaries so the field is present across start, resume, fork, list, read, rollback, metadata-update, unarchive, and `thread/started` paths. See [`load_thread_from_resume_source_or_send_internal`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/src/request_processors/thread_processor.rs#L2824-L2918) and [`thread_from_stored_thread`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/src/request_processors/thread_processor.rs#L3671-L3719). - Preserved the stored-thread fallback: if a thread has not been loaded into a live session tree yet, `thread.sessionId` falls back to `thread.id`; once the thread is live again, the field reports the active session tree root. - Regenerated the JSON/TypeScript schemas and updated the app-server README examples to show [`thread.sessionId`](https://github.com/openai/codex/blob/8fc9e9b4cf81b6f61d432e71f1eb266f6f104b63/codex-rs/app-server/README.md#L306-L310) on the thread object. --- .../analytics/src/analytics_client_tests.rs | 3 +- codex-rs/analytics/src/client_tests.rs | 4 +- .../schema/json/ServerNotification.json | 5 ++ .../codex_app_server_protocol.schemas.json | 20 ++---- .../codex_app_server_protocol.v2.schemas.json | 20 ++---- .../schema/json/v2/ThreadForkResponse.json | 10 +-- .../schema/json/v2/ThreadListResponse.json | 5 ++ .../json/v2/ThreadMetadataUpdateResponse.json | 5 ++ .../schema/json/v2/ThreadReadResponse.json | 5 ++ .../schema/json/v2/ThreadResumeResponse.json | 10 +-- .../json/v2/ThreadRollbackResponse.json | 5 ++ .../schema/json/v2/ThreadStartResponse.json | 10 +-- .../json/v2/ThreadStartedNotification.json | 5 ++ .../json/v2/ThreadUnarchiveResponse.json | 5 ++ .../schema/typescript/v2/Thread.ts | 4 ++ .../typescript/v2/ThreadForkResponse.ts | 5 +- .../typescript/v2/ThreadResumeResponse.ts | 5 +- .../typescript/v2/ThreadStartResponse.ts | 5 +- .../src/protocol/common.rs | 4 +- .../src/protocol/v2/tests.rs | 3 +- .../src/protocol/v2/thread.rs | 9 --- .../src/protocol/v2/thread_data.rs | 2 + codex-rs/app-server/README.md | 4 +- .../app-server/src/bespoke_event_handling.rs | 4 ++ .../request_processors/thread_lifecycle.rs | 2 +- .../request_processors/thread_processor.rs | 61 +++++++++++++------ .../src/request_processors/thread_summary.rs | 4 +- .../src/request_processors/turn_processor.rs | 1 + codex-rs/app-server/src/thread_status.rs | 1 + codex-rs/app-server/tests/suite/v2/review.rs | 1 + .../app-server/tests/suite/v2/thread_fork.rs | 16 +++-- .../app-server/tests/suite/v2/thread_list.rs | 1 + .../tests/suite/v2/thread_metadata_update.rs | 5 ++ .../tests/suite/v2/thread_resume.rs | 9 +-- .../tests/suite/v2/thread_rollback.rs | 5 ++ .../app-server/tests/suite/v2/thread_start.rs | 16 ++++- codex-rs/exec/src/lib.rs | 4 +- codex-rs/exec/src/lib_tests.rs | 3 +- codex-rs/tui/src/app/loaded_threads.rs | 1 + codex-rs/tui/src/app/tests.rs | 4 ++ codex-rs/tui/src/app/thread_session_state.rs | 1 + codex-rs/tui/src/app_server_session.rs | 2 +- codex-rs/tui/src/resume_picker.rs | 4 ++ 43 files changed, 186 insertions(+), 112 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 7194e32443..121a378566 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -128,6 +128,7 @@ fn sample_thread_with_metadata( ) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral, @@ -154,7 +155,6 @@ fn sample_thread_start_response( model: &str, ) -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { - session_id: format!("session-{thread_id}"), thread: sample_thread_with_metadata( thread_id, ephemeral, @@ -216,7 +216,6 @@ fn sample_thread_resume_response_with_source( thread_source: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - session_id: format!("session-{thread_id}"), thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), model: model.to_string(), model_provider: "openai".to_string(), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 510378c20d..3021d558d6 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -76,6 +76,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: false, @@ -102,7 +103,6 @@ fn sample_permission_profile() -> AppServerPermissionProfile { fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { - session_id: "session-1".to_string(), thread: sample_thread("thread-1"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -120,7 +120,6 @@ fn sample_thread_start_response() -> ClientResponsePayload { fn sample_thread_resume_response() -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - session_id: "session-2".to_string(), thread: sample_thread("thread-2"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -138,7 +137,6 @@ fn sample_thread_resume_response() -> ClientResponsePayload { fn sample_thread_fork_response() -> ClientResponsePayload { ClientResponsePayload::ThreadFork(ThreadForkResponse { - session_id: "session-3".to_string(), thread: sample_thread("thread-3"), model: "gpt-5".to_string(), model_provider: "openai".to_string(), diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f5de1f456c..883ab8e68a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3072,6 +3072,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -3120,6 +3124,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", 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 20279bf010..c4f69f47ea 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 @@ -15345,6 +15345,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -15393,6 +15397,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -15664,11 +15669,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -17176,11 +17176,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/v2/Thread" } @@ -17504,11 +17499,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/v2/Thread" } 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 50a19200a5..fdd5ba9084 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 @@ -13231,6 +13231,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -13279,6 +13283,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -13550,11 +13555,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } @@ -15062,11 +15062,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } @@ -15390,11 +15385,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 902a6f4c9f..93f5ee18b1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1403,6 +1403,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1455,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2619,11 +2624,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index aaeb3a5ab2..f78fbaf27e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6237f8b746..4268ad203a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 4a64c50252..fb0d80a047 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index b00fd513e7..bb1290ecbe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1403,6 +1403,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1455,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2619,11 +2624,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f495e4cdfe..204828c732 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 7451d08977..f1354d70b1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1403,6 +1403,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1451,6 +1455,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2619,11 +2624,6 @@ } ] }, - "sessionId": { - "default": "", - "description": "Session id shared by threads that belong to the same session tree.", - "type": "string" - }, "thread": { "$ref": "#/definitions/Thread" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 98c163a41e..759b5990be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 5e26982d51..f64400129a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -901,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 99e622565a..d917094e36 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -9,6 +9,10 @@ import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index b0bb9caea6..ddcef104e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {/** - * Session id shared by threads that belong to the same session tree. - */ -sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index cc4c2440f7..f7627c07ae 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {/** - * Session id shared by threads that belong to the same session tree. - */ -sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 962ed2437e..ce28a4a1d7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -9,10 +9,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {/** - * Session id shared by threads that belong to the same session tree. - */ -sessionId: string, thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 8fd267d014..682556b976 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2176,9 +2176,9 @@ mod tests { let response = ClientResponse::ThreadStart { request_id: RequestId::Integer(7), response: v2::ThreadStartResponse { - session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: true, @@ -2218,9 +2218,9 @@ mod tests { "method": "thread/start", "id": 7, "response": { - "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, "preview": "first prompt", "ephemeral": true, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index dba9c24108..9b82120c93 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3437,10 +3437,11 @@ fn thread_start_params_preserve_explicit_null_service_tier() { } #[test] -fn thread_lifecycle_responses_default_missing_compat_fields() { +fn thread_lifecycle_responses_default_missing_optional_fields() { let response = json!({ "thread": { "id": "thread-id", + "sessionId": "thread-id", "forkedFromId": null, "preview": "", "ephemeral": false, 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 5a09c2e665..578ef9193f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -189,9 +189,6 @@ pub struct MockExperimentalMethodResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadStartResponse { - /// Session id shared by threads that belong to the same session tree. - #[serde(default)] - pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, @@ -307,9 +304,6 @@ pub struct ThreadResumeParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadResumeResponse { - /// Session id shared by threads that belong to the same session tree. - #[serde(default)] - pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, @@ -419,9 +413,6 @@ pub struct ThreadForkParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ThreadForkResponse { - /// Session id shared by threads that belong to the same session tree. - #[serde(default)] - pub session_id: String, pub thread: Thread, pub model: String, pub model_provider: String, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index cb02705849..f0c518adf8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -104,6 +104,8 @@ pub struct GitInfo { #[ts(export_to = "v2/")] pub struct Thread { pub id: String, + /// Session id shared by threads that belong to the same session tree. + pub session_id: String, /// Source thread id when this thread was created by forking another thread. pub forked_from_id: Option, /// Usually the first user message in the thread, if available. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8aeeb7df4f..ddc3817952 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -303,11 +303,11 @@ Example: { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The response includes the forked thread's `sessionId`, so clients do not need to infer it from the new thread id. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "sessionId": "thr_456", "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6ff7f5f03f..e2d8fd587d 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1190,6 +1190,7 @@ pub(crate) async fn apply_bespoke_event_handling( .await; let response = match thread_rollback_response_from_stored_thread( stored_thread, + conversation.session_configured().session_id.to_string(), fallback_model_provider.as_str(), &fallback_cwd, loaded_status, @@ -1543,6 +1544,7 @@ async fn handle_thread_rollback_failed( fn thread_rollback_response_from_stored_thread( stored_thread: codex_thread_store::StoredThread, + session_id: String, fallback_model_provider: &str, fallback_cwd: &AbsolutePathBuf, loaded_status: ThreadStatus, @@ -1550,6 +1552,7 @@ fn thread_rollback_response_from_stored_thread( let thread_id = stored_thread.thread_id; let (mut thread, history) = thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd); + thread.session_id = session_id; let Some(history) = history else { return Err(format!( "thread {thread_id} did not include persisted history after rollback" @@ -2200,6 +2203,7 @@ mod tests { let response = thread_rollback_response_from_stored_thread( stored_thread, + thread_id.to_string(), "fallback-provider", &fallback_cwd, ThreadStatus::NotLoaded, diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index bd93893b79..81e09c7d5c 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -599,9 +599,9 @@ pub(super) async fn handle_pending_thread_resume_request( let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); let session_id = conversation.session_configured().session_id.to_string(); + thread.session_id = session_id; let response = ThreadResumeResponse { - session_id, thread, model, model_provider: model_provider_id, 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 7cef88b14b..aaf7fb1de1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1096,6 +1096,7 @@ impl ThreadRequestProcessor { .await; let mut thread = build_thread_from_snapshot( thread_id, + session_configured.session_id.to_string(), &config_snapshot, session_configured.rollout_path.clone(), ); @@ -1148,7 +1149,6 @@ impl ThreadRequestProcessor { thread_response_active_permission_profile(config_snapshot.active_permission_profile); let response = ThreadStartResponse { - session_id: session_configured.session_id.to_string(), thread: thread.clone(), model: config_snapshot.model, model_provider: config_snapshot.model_provider_id, @@ -1529,9 +1529,9 @@ impl ThreadRequestProcessor { ..Default::default() }; + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); let updated_thread = { let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); if let Some(loaded_thread) = loaded_thread.as_ref() { if loaded_thread.config_snapshot().await.ephemeral { return Err(invalid_request(format!( @@ -1552,12 +1552,14 @@ impl ThreadRequestProcessor { } .map_err(|err| thread_store_write_error("update thread metadata", err))? }; - let (mut thread, _) = thread_from_stored_thread( updated_thread, self.config.model_provider_id.as_str(), &self.config.cwd, ); + if let Some(loaded_thread) = loaded_thread.as_ref() { + thread.session_id = loaded_thread.session_configured().session_id.to_string(); + } self.attach_thread_name(thread_uuid, &mut thread).await; thread.status = resolve_thread_status( self.thread_watch_manager @@ -1603,18 +1605,16 @@ impl ThreadRequestProcessor { .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; let fallback_provider = self.config.model_provider_id.clone(); - let mut thread = self + let stored_thread = self .thread_store .unarchive_thread(StoreArchiveThreadParams { thread_id }) .await - .map_err(|err| thread_store_archive_error("unarchive", err)) - .and_then(|stored_thread| { - summary_from_stored_thread(stored_thread, fallback_provider.as_str()) - .map(|summary| summary_to_thread(summary, &self.config.cwd)) - .ok_or_else(|| { - internal_error(format!("failed to read unarchived thread {thread_id}")) - }) + .map_err(|err| thread_store_archive_error("unarchive", err))?; + let summary = summary_from_stored_thread(stored_thread, fallback_provider.as_str()) + .ok_or_else(|| { + internal_error(format!("failed to read unarchived thread {thread_id}")) })?; + let mut thread = summary_to_thread(summary, &self.config.cwd); thread.status = resolve_thread_status( self.thread_watch_manager @@ -2040,6 +2040,7 @@ impl ThreadRequestProcessor { if thread.path.is_none() { thread.path = fallback_thread.path.clone(); } + thread.session_id.clone_from(&fallback_thread.session_id); thread.ephemeral = fallback_thread.ephemeral; thread } else { @@ -2223,8 +2224,12 @@ impl ThreadRequestProcessor { ) { if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { let config_snapshot = thread.config_snapshot().await; - let loaded_thread = - build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path()); + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); self.thread_watch_manager.upsert_thread(loaded_thread).await; } @@ -2477,7 +2482,6 @@ impl ThreadRequestProcessor { ); let response = ThreadResumeResponse { - session_id: session_configured.session_id.to_string(), thread, model: session_configured.model, model_provider: session_configured.model_provider_id, @@ -2651,11 +2655,12 @@ impl ThreadRequestProcessor { } let mut summary_source_thread = source_thread; summary_source_thread.history = None; - let thread_summary = self.stored_thread_to_api_thread( + let mut thread_summary = self.stored_thread_to_api_thread( summary_source_thread, config_snapshot.model_provider_id.as_str(), /*include_turns*/ false, ); + thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); let mut config_for_instruction_sources = self.config.as_ref().clone(); config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); let instruction_sources = @@ -2824,6 +2829,7 @@ impl ThreadRequestProcessor { include_turns: bool, ) -> std::result::Result { let config_snapshot = thread.config_snapshot().await; + let session_id = thread.session_configured().session_id.to_string(); let thread = match thread_history { InitialHistory::Resumed(resumed) => { let fallback_provider = config_snapshot.model_provider_id.as_str(); @@ -2885,6 +2891,7 @@ impl ThreadRequestProcessor { InitialHistory::Forked(items) => { let mut thread = build_thread_from_snapshot( thread_id, + session_id.clone(), &config_snapshot, Some(rollout_path.into()), ); @@ -2897,6 +2904,7 @@ impl ThreadRequestProcessor { }; let mut thread = thread?; thread.id = thread_id.to_string(); + thread.session_id = session_id; thread.path = Some(rollout_path.to_path_buf()); if include_turns { let history_items = thread_history.get_rollout_items(); @@ -3078,8 +3086,12 @@ impl ThreadRequestProcessor { } else { let config_snapshot = forked_thread.config_snapshot().await; // forked thread names do not inherit the source thread name - let mut thread = - build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + /*path*/ None, + ); thread.preview = preview_from_rollout_items(&history_items); thread.forked_from_id = Some(source_thread_id.to_string()); if include_turns { @@ -3091,6 +3103,7 @@ impl ThreadRequestProcessor { } thread }; + thread.session_id = session_configured.session_id.to_string(); thread.thread_source = forked_thread .config_snapshot() .await @@ -3116,7 +3129,6 @@ impl ThreadRequestProcessor { thread_response_active_permission_profile(config_snapshot.active_permission_profile); let response = ThreadForkResponse { - session_id: session_configured.session_id.to_string(), thread: thread.clone(), model: session_configured.model, model_provider: session_configured.model_provider_id, @@ -3680,8 +3692,10 @@ pub(crate) fn thread_from_stored_thread( thread.agent_role.clone(), ); let history = thread.history; + let thread_id = thread.thread_id.to_string(); let thread = Thread { - id: thread.thread_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), preview: thread.first_user_message.unwrap_or(thread.preview), ephemeral: false, @@ -3878,12 +3892,14 @@ fn permission_profile_trusts_project( fn build_thread_from_snapshot( thread_id: ThreadId, + session_id: String, config_snapshot: &ThreadConfigSnapshot, path: Option, ) -> Thread { let now = time::OffsetDateTime::now_utc().unix_timestamp(); Thread { id: thread_id.to_string(), + session_id, forked_from_id: None, preview: String::new(), ephemeral: config_snapshot.ephemeral, @@ -3909,7 +3925,12 @@ fn build_thread_from_loaded_snapshot( config_snapshot: &ThreadConfigSnapshot, loaded_thread: &CodexThread, ) -> Thread { - build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path()) + build_thread_from_snapshot( + thread_id, + loaded_thread.session_configured().session_id.to_string(), + config_snapshot, + loaded_thread.rollout_path(), + ) } #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index be3000e36a..f2de590a9a 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -263,8 +263,10 @@ pub(crate) fn summary_to_thread( fallback_cwd.clone() }); + let thread_id = conversation_id.to_string(); Thread { - id: conversation_id.to_string(), + id: thread_id.clone(), + session_id: thread_id, forked_from_id: None, preview, ephemeral: false, 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 a3783f995d..ad084f16c3 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -935,6 +935,7 @@ impl TurnRequestProcessor { Ok(stored_thread) => { let (mut thread, _) = thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + thread.session_id = review_thread.session_configured().session_id.to_string(); self.thread_watch_manager .upsert_thread_silently(thread.clone()) .await; diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index 47da7e8cad..7315a13c02 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -889,6 +889,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index 277885c5a8..bf0271f821 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -363,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let started: ThreadStartedNotification = serde_json::from_value(notification.params.expect("params must be present"))?; assert_eq!(started.thread.id, review_thread_id); + assert_eq!(started.thread.session_id, review_thread_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 4a6644e30a..8394f9839e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -101,20 +101,28 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ) .await??; let fork_result = fork_resp.result.clone(); - let ThreadForkResponse { - session_id, thread, .. - } = to_response::(fork_resp)?; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; // Wire contract: thread title field is `name`, serialized as null when unset. let thread_json = fork_result .get("thread") .and_then(Value::as_object) .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "forked threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "forked threads do not inherit a name; expected `name: null`" ); + assert_eq!( + fork_result.get("sessionId"), + None, + "thread/fork should not serialize a top-level `sessionId`" + ); let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( @@ -123,7 +131,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); - assert_eq!(session_id, thread.id); + assert_eq!(thread.session_id, thread.id); assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index ebaba81852..80254d8f47 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -879,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() assert_eq!(ids, vec![subagent_id.as_str()]); assert_ne!(cli_id, subagent_id); assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + assert_eq!(data[0].session_id, subagent_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index 430f8e5392..c78e9b8152 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -77,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() to_response::(update_resp)?; assert_eq!(updated.id, thread.id); + assert_eq!(updated.session_id, thread.session_id); assert_eq!( updated.git_info, Some(GitInfo { @@ -90,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() .get("thread") .and_then(Value::as_object) .expect("thread/metadata/update result.thread must be an object"); + assert_eq!( + updated_thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); let updated_git_info_json = updated_thread_json .get("gitInfo") .and_then(Value::as_object) diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 175da83016..7d7de25515 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -270,10 +270,11 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { - session_id, thread, .. - } = to_response::(resume_resp)?; - assert!(!session_id.is_empty(), "session id should not be empty"); + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert_eq!(thread.thread_source, Some(ThreadSource::User)); let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 3487b9e36a..5f79db0e26 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() .and_then(Value::as_object) .expect("thread/rollback result.thread must be an object"); assert_eq!(rolled_back_thread.name, None); + assert_eq!(rolled_back_thread.session_id, thread.session_id); assert_eq!( thread_json.get("name"), Some(&Value::Null), "thread/rollback must serialize `name: null` when unset" ); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index e300e562d9..4da6b54ed1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -121,12 +121,14 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .await??; let resp_result = resp.result.clone(); let ThreadStartResponse { - session_id, thread, model_provider, .. } = to_response::(resp)?; - assert!(!session_id.is_empty(), "session id should not be empty"); + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert!(!thread.id.is_empty(), "thread id should not be empty"); assert!( thread.preview.is_empty(), @@ -155,11 +157,21 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "new threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "new threads should serialize `name: null`" ); + assert_eq!( + resp_result.get("sessionId"), + None, + "thread/start should not serialize a top-level `sessionId`" + ); assert_eq!( thread_json.get("ephemeral").and_then(Value::as_bool), Some(false), diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 5ff19c744b..b4bbb2c1d5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1060,7 +1060,7 @@ fn session_configured_from_thread_start_response( config: &Config, ) -> Result { session_configured_from_thread_response( - &response.session_id, + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), @@ -1085,7 +1085,7 @@ fn session_configured_from_thread_resume_response( config: &Config, ) -> Result { session_configured_from_thread_response( - &response.session_id, + &response.thread.session_id, &response.thread.id, response.thread.name.clone(), response.thread.path.clone(), diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index ad528a194d..caa2921d19 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -244,6 +244,7 @@ async fn resume_lookup_model_providers_filters_only_last_lookup() { fn turn_items_for_thread_returns_matching_turn_items() { let thread = AppServerThread { id: "thread-1".to_string(), + session_id: "thread-1".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -473,9 +474,9 @@ async fn session_configured_from_thread_response_uses_permission_profile_from_re fn sample_thread_start_response() -> ThreadStartResponse { ThreadStartResponse { - session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), thread: codex_app_server_protocol::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/tui/src/app/loaded_threads.rs b/codex-rs/tui/src/app/loaded_threads.rs index 49e5568596..0ab8e14ee3 100644 --- a/codex-rs/tui/src/app/loaded_threads.rs +++ b/codex-rs/tui/src/app/loaded_threads.rs @@ -118,6 +118,7 @@ mod tests { fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 3f061fa516..8598c035a9 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -2940,6 +2940,7 @@ async fn inactive_thread_started_notification_initializes_replay_session() -> Re ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -3022,6 +3023,7 @@ async fn inactive_thread_started_notification_preserves_primary_model_when_path_ ServerNotification::ThreadStarted(ThreadStartedNotification { thread: Thread { id: agent_thread_id.to_string(), + session_id: agent_thread_id.to_string(), forked_from_id: None, preview: "agent thread".to_string(), ephemeral: false, @@ -3077,6 +3079,7 @@ async fn thread_read_session_state_does_not_reuse_primary_permission_profile() { let thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, @@ -5041,6 +5044,7 @@ async fn thread_rollback_response_discards_queued_active_thread_events() { &ThreadRollbackResponse { thread: Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index ac6f6311ca..15d034cc3d 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -322,6 +322,7 @@ mod tests { }; let read_thread = Thread { id: read_thread_id.to_string(), + session_id: read_thread_id.to_string(), forked_from_id: None, preview: "read thread".to_string(), ephemeral: false, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 12f2fdcf62..16f2f05d0c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1813,9 +1813,9 @@ mod tests { let forked_from_id = ThreadId::new(); let read_only_profile = PermissionProfile::read_only(); let response = ThreadResumeResponse { - session_id: ThreadId::new().to_string(), thread: codex_app_server_protocol::Thread { id: thread_id.to_string(), + session_id: ThreadId::new().to_string(), forked_from_id: Some(forked_from_id.to_string()), preview: "hello".to_string(), ephemeral: false, diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 171a1c3513..08bd6657bf 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5684,6 +5684,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("remote thread"), ephemeral: false, @@ -5717,6 +5718,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, @@ -5783,6 +5785,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, @@ -5839,6 +5842,7 @@ session_picker_view = "dense" let thread_id = ThreadId::new(); let thread = Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::from("preview"), ephemeral: false, From ebd9ec05b404bf9fbf339f6185f52e435dc5c982 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 16:06:21 +0200 Subject: [PATCH 17/30] [codex] fix builtin MCP Windows path test (#21350) ## Summary - make the builtin MCP config test derive the expected `--codex-home` argument from `AbsolutePathBuf` ## Why `AbsolutePathBuf::try_from("/tmp/codex-home")` is rendered as `D:\\tmp\\codex-home` on Windows, but the test asserted the Unix literal `"/tmp/codex-home"`. That made the Windows Bazel job fail even though the production code was behaving correctly. ## Impact This keeps the test cross-platform while preserving the same transport assertion on Unix and Windows. ## Validation - `cargo test -p codex-builtin-mcps` Co-authored-by: Codex --- codex-rs/builtin-mcps/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/builtin-mcps/src/lib.rs b/codex-rs/builtin-mcps/src/lib.rs index 3fd4483dc1..abecc994d6 100644 --- a/codex-rs/builtin-mcps/src/lib.rs +++ b/codex-rs/builtin-mcps/src/lib.rs @@ -109,7 +109,7 @@ mod tests { "builtin-mcp".to_string(), "memories".to_string(), "--codex-home".to_string(), - "/tmp/codex-home".to_string(), + codex_home.as_path().to_string_lossy().into_owned(), ], env: None, env_vars: Vec::new(), From be1d3cff9300c7d72d4adab2b31c19e8a8bb27ca Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 6 May 2026 18:00:21 +0300 Subject: [PATCH 18/30] 2- Use string service tiers in session protocol (#20971) ## Summary - break service tier session/op/app-server protocol fields from the closed enum to string tier ids - send the service tier string directly through model requests, prewarm, compaction, memories, and TUI/app-server turn starts - regenerate app-server protocol JSON/TypeScript schemas, removing the standalone ServiceTier TS enum ## Verification - just fmt - cargo check -p codex-core -p codex-app-server -p codex-tui - just write-app-server-schema --------- Co-authored-by: Codex --- .../schema/json/ClientRequest.json | 79 ++--------- .../codex_app_server_protocol.schemas.json | 129 ++++-------------- .../codex_app_server_protocol.v2.schemas.json | 129 ++++-------------- .../schema/json/v2/ConfigReadResponse.json | 27 +--- .../schema/json/v2/ThreadForkParams.json | 24 +--- .../schema/json/v2/ThreadForkResponse.json | 17 +-- .../schema/json/v2/ThreadResumeParams.json | 24 +--- .../schema/json/v2/ThreadResumeResponse.json | 17 +-- .../schema/json/v2/ThreadStartParams.json | 24 +--- .../schema/json/v2/ThreadStartResponse.json | 17 +-- .../schema/json/v2/TurnStartParams.json | 28 +--- .../schema/typescript/ServiceTier.ts | 5 - .../schema/typescript/index.ts | 1 - .../schema/typescript/v2/Config.ts | 3 +- .../schema/typescript/v2/ProfileV2.ts | 3 +- .../schema/typescript/v2/ThreadForkParams.ts | 3 +- .../typescript/v2/ThreadForkResponse.ts | 3 +- .../typescript/v2/ThreadResumeParams.ts | 3 +- .../typescript/v2/ThreadResumeResponse.ts | 3 +- .../schema/typescript/v2/ThreadStartParams.ts | 3 +- .../typescript/v2/ThreadStartResponse.ts | 3 +- .../schema/typescript/v2/TurnStartParams.ts | 3 +- .../src/protocol/v2/config.rs | 5 +- .../src/protocol/v2/thread.rs | 13 +- .../src/protocol/v2/turn.rs | 3 +- .../request_processors/thread_processor.rs | 2 +- .../thread_processor_tests.rs | 6 +- .../src/request_processors/turn_processor.rs | 2 +- .../app-server/tests/suite/v2/thread_start.rs | 8 +- .../app-server/tests/suite/v2/turn_start.rs | 65 +++++++++ codex-rs/core/src/client.rs | 25 ++-- codex-rs/core/src/codex_thread.rs | 5 +- codex-rs/core/src/compact.rs | 2 +- codex-rs/core/src/compact_remote.rs | 2 +- codex-rs/core/src/compact_remote_v2.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 23 ++++ codex-rs/core/src/config/mod.rs | 26 ++-- codex-rs/core/src/guardian/tests.rs | 2 +- codex-rs/core/src/session/config_lock.rs | 7 +- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 20 ++- codex-rs/core/src/session/tests.rs | 27 +++- codex-rs/core/src/session/turn.rs | 9 +- codex-rs/core/src/session/turn_context.rs | 2 +- codex-rs/core/src/session_startup_prewarm.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 6 +- codex-rs/core/tests/suite/agent_websocket.rs | 2 +- .../core/tests/suite/client_websockets.rs | 2 +- codex-rs/core/tests/suite/compact_remote.rs | 2 +- codex-rs/exec/src/lib.rs | 6 +- codex-rs/memories/write/src/runtime.rs | 5 +- codex-rs/memories/write/src/startup_tests.rs | 17 ++- codex-rs/protocol/src/config_types.rs | 17 +++ codex-rs/protocol/src/protocol.rs | 11 +- codex-rs/tui/src/app/event_dispatch.rs | 3 +- codex-rs/tui/src/app/session_lifecycle.rs | 5 +- codex-rs/tui/src/app/tests.rs | 6 +- codex-rs/tui/src/app/thread_routing.rs | 2 +- codex-rs/tui/src/app/thread_session_state.rs | 5 +- codex-rs/tui/src/app_command.rs | 9 +- codex-rs/tui/src/app_server_session.rs | 10 +- codex-rs/tui/src/chatwidget.rs | 24 +++- codex-rs/tui/src/chatwidget/tests/helpers.rs | 5 +- .../src/chatwidget/tests/slash_commands.rs | 20 +-- codex-rs/tui/src/session_state.rs | 2 +- 65 files changed, 403 insertions(+), 570 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 35efe8695b..36596807ba 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3324,13 +3324,6 @@ ], "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -3608,20 +3601,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -4030,20 +4012,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -4227,20 +4198,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -4407,22 +4367,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ 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 c4f69f47ea..acb4b16d93 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 @@ -7224,13 +7224,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -13089,13 +13085,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -14624,13 +14616,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -15573,20 +15558,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -15660,13 +15634,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17091,20 +17061,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -17167,13 +17126,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17399,20 +17354,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -17490,13 +17434,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -18163,22 +18103,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ 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 fdd5ba9084..ded3dc2b87 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 @@ -3680,13 +3680,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -9700,13 +9696,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -12510,13 +12502,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -13459,20 +13444,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -13546,13 +13520,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14977,20 +14947,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -15053,13 +15012,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -15285,20 +15240,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -15376,13 +15320,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -16049,22 +15989,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c348e7d955..87a826e5af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,13 +352,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -658,13 +654,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -754,13 +746,6 @@ }, "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ToolsV2": { "properties": { "view_image": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 6419bc9422..29d67403cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -131,13 +131,6 @@ ], "type": "string" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ThreadSource": { "enum": [ "user", @@ -222,20 +215,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 93f5ee18b1..6e74ab4ac8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -2615,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 9fe5c7f47f..5f07fe0149 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1010,13 +1010,6 @@ "danger-full-access" ], "type": "string" - }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -1101,20 +1094,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index bb1290ecbe..727b7a3fb2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -2615,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index aa5029afd9..9a60049a61 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -165,13 +165,6 @@ ], "type": "string" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ThreadSource": { "enum": [ "user", @@ -295,20 +288,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index f1354d70b1..bf03f0fb55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -2615,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index da1320a796..1ef33d4301 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -312,13 +312,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -586,22 +579,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts deleted file mode 100644 index ce11286dbd..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ /dev/null @@ -1,5 +0,0 @@ -// 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 ServiceTier = "fast" | "flex"; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index a082e045fa..97ea435601 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -64,7 +64,6 @@ export type { ResponseItem } from "./ResponseItem"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; -export type { ServiceTier } from "./ServiceTier"; export type { SessionSource } from "./SessionSource"; export type { Settings } from "./Settings"; export type { SubAgentSource } from "./SubAgentSource"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92..cc7e340ea3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -4,7 +4,6 @@ import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -20,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 7afe3e0c54..d05038701c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -3,7 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -16,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap * are routed for review. If omitted, the enclosing config default is * used. */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index ea67b491ad..6076a4bb14 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -1,7 +1,6 @@ // 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. -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -20,7 +19,7 @@ import type { ThreadSource } from "./ThreadSource"; export type ThreadForkParams = {threadId: string, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..c44533ec1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index ac8b1e293b..6d1dbdca4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -22,7 +21,7 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadResumeParams = {threadId: string, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f7627c07ae..f91756c7c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index cecc183f92..30509ef6cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -10,7 +9,7 @@ import type { SandboxMode } from "./SandboxMode"; import type { ThreadSource } from "./ThreadSource"; import type { ThreadStartSource } from "./ThreadStartSource"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index ce28a4a1d7..9573bd7dee 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 4af17115c8..b04919d86b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -4,7 +4,6 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -30,7 +29,7 @@ sandboxPolicy?: SandboxPolicy | null, /** model?: string | null, /** * Override the service tier for this turn and subsequent turns. */ -serviceTier?: ServiceTier | null | null, /** +serviceTier?: string | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 1c1ef35d62..da4f493d77 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -5,7 +5,6 @@ use super::shared::default_enabled; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; @@ -137,7 +136,7 @@ pub struct ProfileV2 { /// used. #[experimental("config/read.approvalsReviewer")] pub approvals_reviewer: Option, - pub service_tier: Option, + pub service_tier: Option, pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, @@ -248,7 +247,7 @@ pub struct Config { pub model_reasoning_effort: Option, pub model_reasoning_summary: Option, pub model_verbosity: Option, - pub service_tier: Option, + pub service_tier: Option, pub analytics: Option, #[experimental("config/read.apps")] #[serde(default)] 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 578ef9193f..5f293ff8f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -12,7 +12,6 @@ use super::TurnEnvironmentParams; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -103,7 +102,7 @@ pub struct ThreadStartParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -192,7 +191,7 @@ pub struct ThreadStartResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] @@ -260,7 +259,7 @@ pub struct ThreadResumeParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -307,7 +306,7 @@ pub struct ThreadResumeResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] @@ -366,7 +365,7 @@ pub struct ThreadForkParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, #[experimental(nested)] @@ -416,7 +415,7 @@ pub struct ThreadForkResponse { pub thread: Thread, pub model: String, pub model_provider: String, - pub service_tier: Option, + pub service_tier: Option, pub cwd: AbsolutePathBuf, /// Instruction source files currently loaded for this thread. #[serde(default)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 38118ab15e..61a09bfbf5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -7,7 +7,6 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; @@ -94,7 +93,7 @@ pub struct TurnStartParams { skip_serializing_if = "Option::is_none" )] #[ts(optional = nullable)] - pub service_tier: Option>, + pub service_tier: Option>, /// Override the reasoning effort for this turn and subsequent turns. #[ts(optional = nullable)] pub effort: Option, 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 aaf7fb1de1..783e53ccb0 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1188,7 +1188,7 @@ impl ThreadRequestProcessor { &self, model: Option, model_provider: Option, - service_tier: Option>, + service_tier: Option>, cwd: Option, approval_policy: Option, approvals_reviewer: Option, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 01c09de43b..5a18c09db9 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -629,7 +629,7 @@ mod thread_processor_behavior_tests { path: None, model: None, model_provider: None, - service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), + service_tier: Some(Some("priority".to_string())), cwd: None, approval_policy: None, approvals_reviewer: None, @@ -645,7 +645,7 @@ mod thread_processor_behavior_tests { let config_snapshot = ThreadConfigSnapshot { model: "gpt-5".to_string(), model_provider_id: "openai".to_string(), - service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), + service_tier: Some("flex".to_string()), approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, permission_profile: codex_protocol::models::PermissionProfile::Disabled, @@ -660,7 +660,7 @@ mod thread_processor_behavior_tests { assert_eq!( collect_resume_override_mismatches(&request, &config_snapshot), - vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()] + vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()] ); } 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 ad084f16c3..b207fde940 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -438,7 +438,7 @@ impl TurnRequestProcessor { model: model.clone(), effort, summary, - service_tier, + service_tier: service_tier.clone(), collaboration_mode: collaboration_mode.clone(), personality, }) diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 4da6b54ed1..78155d8c9a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -28,7 +28,6 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -402,7 +401,7 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_flex_service_tier() -> Result<()> { +async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -411,9 +410,10 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let service_tier_id = "experimental-tier-id".to_string(); let req_id = mcp .send_thread_start_request(ThreadStartParams { - service_tier: Some(Some(ServiceTier::Flex)), + service_tier: Some(Some(service_tier_id.clone())), ..Default::default() }) .await?; @@ -425,7 +425,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(ServiceTier::Flex)); + assert_eq!(service_tier, Some(service_tier_id)); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 971b081ae5..e5c5c5adbb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -358,6 +358,71 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( Ok(()) } +#[tokio::test] +async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let service_tier_id = "experimental-tier-id".to_string(); + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + service_tier: Some(Some(service_tier_id.clone())), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 6449237dd6..4806633a3f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,7 +73,6 @@ use codex_otel::current_span_w3c_trace_context; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -150,7 +149,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = pub(crate) struct CompactConversationRequestSettings { pub(crate) effort: Option, pub(crate) summary: ReasoningSummaryConfig, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, } /// Session-scoped state shared by all [`ModelClient`] clones. @@ -683,7 +682,7 @@ impl ModelClient { model_info: &ModelInfo, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, ) -> Result { let instructions = &prompt.base_instructions.text; let input = prompt.get_formatted_input(); @@ -722,11 +721,7 @@ impl ModelClient { store: provider.is_azure_responses_endpoint(), stream: true, include, - service_tier: match service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, + service_tier, prompt_cache_key, text, client_metadata: Some(HashMap::from([( @@ -1168,7 +1163,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1215,7 +1210,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.record_started(&request); @@ -1293,7 +1288,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, request_trace: Option, @@ -1321,7 +1316,7 @@ impl ModelClientSession { model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( @@ -1453,7 +1448,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { if !self.client.responses_websocket_enabled() { @@ -1514,7 +1509,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1530,7 +1525,7 @@ impl ModelClientSession { session_telemetry, effort, summary, - service_tier, + service_tier.clone(), turn_metadata_header, /*warmup*/ false, request_trace, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 508e8c2fac..24a976637b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -11,7 +11,6 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -53,7 +52,7 @@ use codex_rollout::state_db::StateDbHandle; pub struct ThreadConfigSnapshot { pub model: String, pub model_provider_id: String, - pub service_tier: Option, + pub service_tier: Option, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, @@ -91,7 +90,7 @@ pub struct CodexThreadTurnContextOverrides { pub model: Option, pub effort: Option>, pub summary: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub collaboration_mode: Option, pub personality: Option, } diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index a4197acccd..38eb19f1ba 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -524,7 +524,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index cf30a623b3..bc0516ce01 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -174,7 +174,7 @@ async fn run_remote_compact_task_inner_impl( CompactConversationRequestSettings { effort: turn_context.reasoning_effort, summary: turn_context.reasoning_summary, - service_tier: turn_context.config.service_tier, + service_tier: turn_context.config.service_tier.clone(), }, &turn_context.session_telemetry, &compaction_trace, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index f461dd9ce1..7705616d37 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -252,7 +252,7 @@ async fn run_remote_compaction_request_v2( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index f2ae82810a..4c6d1a87e0 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -62,6 +62,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; @@ -7132,6 +7133,28 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: Ok(()) } +#[tokio::test] +async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + #[tokio::test] async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { let fixture = create_test_fixture()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d868e8474c..6dbc9519a9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -402,8 +402,8 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Effective service tier request id preference for new turns. + pub service_tier: Option, /// Model used specifically for review sessions. pub review_model: Option, @@ -1867,7 +1867,7 @@ pub struct ConfigOverrides { pub permission_profile: Option, pub default_permissions: Option, pub model_provider: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, @@ -2735,16 +2735,20 @@ impl Config { notices.fast_default_opt_out = Some(true); None } - None => config_profile.service_tier.or(cfg.service_tier), + None => config_profile + .service_tier + .or(cfg.service_tier) + .map(|service_tier| service_tier.request_value().to_string()), }; - let service_tier = match service_tier { - Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { - Some(ServiceTier::Fast) + let service_tier = service_tier.and_then(|service_tier| { + match ServiceTier::from_request_value(&service_tier) { + Some(ServiceTier::Fast) => features + .enabled(Feature::FastMode) + .then(|| ServiceTier::Fast.request_value().to_string()), + Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None => Some(service_tier), } - Some(ServiceTier::Fast) => None, - Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - None => None, - }; + }); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 78362b6f89..6d03dfa813 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -1894,7 +1894,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() -> #[tokio::test] async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> { - const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024; + const TEST_STACK_SIZE_BYTES: usize = 4 * 1024 * 1024; let handle = std::thread::Builder::new() diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index d1f190510a..10f2242647 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -109,7 +109,10 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con lock_config.model = Some(sc.collaboration_mode.model().to_string()); lock_config.model_reasoning_effort = sc.collaboration_mode.reasoning_effort(); lock_config.model_reasoning_summary = sc.model_reasoning_summary; - lock_config.service_tier = sc.service_tier; + lock_config.service_tier = sc + .service_tier + .as_deref() + .and_then(codex_protocol::config_types::ServiceTier::from_request_value); lock_config.instructions = Some(sc.base_instructions.clone()); lock_config.developer_instructions = sc.developer_instructions.clone(); lock_config.compact_prompt = sc.compact_prompt.clone(); @@ -280,7 +283,7 @@ mod tests { sc.base_instructions = "catalog instructions".to_string(); sc.developer_instructions = Some("catalog developer instructions".to_string()); sc.compact_prompt = Some("catalog compact prompt".to_string()); - sc.service_tier = Some(codex_protocol::config_types::ServiceTier::Flex); + sc.service_tier = Some("flex".to_string()); let lockfile = sc.to_config_lockfile_toml().expect("lock should serialize"); let lock = &lockfile.config; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9d83ec2ff0..bacd1708eb 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -588,7 +588,7 @@ impl Codex { .auth_cached() .and_then(|auth| auth.account_plan_type()); let service_tier = get_service_tier( - config.service_tier, + config.service_tier.clone(), config.notices.fast_default_opt_out.unwrap_or(false), account_plan_type, config.features.enabled(Feature::FastMode), @@ -787,18 +787,18 @@ impl Codex { } fn get_service_tier( - configured_service_tier: Option, + configured_service_tier: Option, fast_default_opt_out: bool, account_plan_type: Option, fast_mode_enabled: bool, -) -> Option { +) -> Option { if configured_service_tier.is_some() || fast_default_opt_out || !fast_mode_enabled { return configured_service_tier; } account_plan_type .is_some_and(is_enterprise_default_service_tier_plan) - .then_some(ServiceTier::Fast) + .then_some(ServiceTier::Fast.request_value().to_string()) } fn is_enterprise_default_service_tier_plan(plan_type: AccountPlanType) -> bool { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 124734a254..3c6acd31e3 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,6 +1,7 @@ use super::*; use crate::goals::GoalRuntimeState; use codex_protocol::SessionId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::ThreadSource; @@ -42,7 +43,7 @@ pub(crate) struct SessionConfiguration { pub(super) collaboration_mode: CollaborationMode, pub(super) model_reasoning_summary: Option, - pub(super) service_tier: Option, + pub(super) service_tier: Option, /// Developer instructions that supplement the base instructions. pub(super) developer_instructions: Option, @@ -136,7 +137,7 @@ impl SessionConfiguration { ThreadConfigSnapshot { model: self.collaboration_mode.model().to_string(), model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), - service_tier: self.service_tier, + service_tier: self.service_tier.clone(), approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, permission_profile: self.permission_profile(), @@ -182,8 +183,15 @@ impl SessionConfiguration { if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); } - if let Some(service_tier) = updates.service_tier { - next_configuration.service_tier = service_tier; + if let Some(service_tier) = updates.service_tier.clone() { + // TODO(aibrahim): Remove once v2 clients no longer send the legacy + // "fast" service tier value. + next_configuration.service_tier = service_tier.map(|service_tier| { + ServiceTier::from_request_value(&service_tier) + .map_or(service_tier, |service_tier| { + service_tier.request_value().to_string() + }) + }); } if let Some(personality) = updates.personality { next_configuration.personality = Some(personality); @@ -309,7 +317,7 @@ pub(crate) struct SessionSettingsUpdate { pub(crate) windows_sandbox_level: Option, pub(crate) collaboration_mode: Option, pub(crate) reasoning_summary: Option, - pub(crate) service_tier: Option>, + pub(crate) service_tier: Option>, pub(crate) final_output_json_schema: Option>, /// Turn-local environment override. `None` inherits the sticky thread /// environments stored on `SessionConfiguration`; `Some([])` explicitly @@ -905,7 +913,7 @@ impl Session { thread_name: session_configuration.thread_name.clone(), model: session_configuration.collaboration_mode.model().to_string(), model_provider_id: config.model_provider_id.clone(), - service_tier: session_configuration.service_tier, + service_tier: session_configuration.service_tier.clone(), approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, permission_profile: session_configuration.permission_profile(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a20f5489c6..a5c5ec0ca4 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2891,7 +2891,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Enterprise), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2900,7 +2900,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::EnterpriseCbpUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2909,7 +2909,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Business), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2918,7 +2918,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::Team), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); assert_eq!( get_service_tier( @@ -2927,7 +2927,7 @@ fn get_service_tier_defaults_enterprise_accounts_to_fast() { Some(AccountPlanType::SelfServeBusinessUsageBased), /*fast_mode_enabled*/ true, ), - Some(ServiceTier::Fast) + Some(ServiceTier::Fast.request_value().to_string()) ); } @@ -2980,6 +2980,23 @@ async fn session_settings_null_service_tier_update_clears_service_tier() { assert_eq!(updated.service_tier, None); } +#[tokio::test] +async fn session_settings_legacy_fast_service_tier_update_uses_priority_request_value() { + let session_configuration = make_session_configuration_for_tests().await; + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }) + .expect("legacy fast service tier update should apply"); + + assert_eq!( + updated.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); +} + pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration { let codex_home = tempfile::tempdir().expect("create temp dir"); let config = build_test_config(codex_home.path()).await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index d74ddd5fe9..7126dfd27a 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -73,6 +73,7 @@ use codex_hooks::HookEventAfterAgent; use codex_hooks::HookPayload; use codex_hooks::HookResult; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::ServiceTier; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::PlanItem; @@ -693,7 +694,11 @@ async fn track_turn_resolved_config_analytics( permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), - service_tier: turn_context.config.service_tier, + service_tier: turn_context + .config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value), approval_policy: turn_context.approval_policy.value(), approvals_reviewer: turn_context.config.approvals_reviewer, sandbox_network_access: turn_context.network_sandbox_policy().is_enabled(), @@ -1858,7 +1863,7 @@ async fn try_run_sampling_request( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, &inference_trace, ) diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8e78a0c9cd..6d8443e933 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -415,7 +415,7 @@ impl Session { per_turn_config.model_reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; - per_turn_config.service_tier = session_configuration.service_tier; + per_turn_config.service_tier = session_configuration.service_tier.clone(); per_turn_config.personality = session_configuration.personality; per_turn_config.approvals_reviewer = session_configuration.approvals_reviewer; per_turn_config.permissions.permission_profile = diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 71afdba6f9..93d14ff7d6 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -232,7 +232,7 @@ async fn schedule_startup_prewarm_inner( &startup_turn_context.session_telemetry, startup_turn_context.reasoning_effort, startup_turn_context.reasoning_summary, - startup_turn_context.config.service_tier, + startup_turn_context.config.service_tier.clone(), startup_turn_metadata_header.as_deref(), ) .await?; diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index ac118615bd..1728f5bc5c 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -654,7 +654,7 @@ impl TestCodex { prompt, AskForApproval::Never, PermissionProfile::Disabled, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*environments*/ None, ) .await @@ -716,7 +716,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { self.submit_turn_with_context( @@ -734,7 +734,7 @@ impl TestCodex { prompt: &str, approval_policy: AskForApproval, permission_profile: PermissionProfile, - service_tier: Option>, + service_tier: Option>, environments: Option>, ) -> Result<()> { let (sandbox_policy, permission_profile) = diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 305346afac..6e985eebe0 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -313,7 +313,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu .features .enable(Feature::ResponsesWebsocketsV2) .expect("test config should allow feature update"); - config.service_tier = Some(ServiceTier::Fast); + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); }); let test = builder.build_with_websocket_server(&server).await?; diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 7ef571af37..8680ee530a 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1948,7 +1948,7 @@ async fn stream_until_complete_with_request_metadata( &harness.session_telemetry, harness.effort, harness.summary, - service_tier, + service_tier.map(|service_tier| service_tier.request_value().to_string()), turn_metadata_header, &codex_rollout_trace::InferenceTraceContext::disabled(), ) diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 5e15b54b77..3da841ba79 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -445,7 +445,7 @@ async fn assert_remote_manual_compact_request_parity( let mut builder = test_codex().with_auth(auth); if let Some(service_tier) = configured_service_tier { builder = builder.with_config(move |config| { - config.service_tier = Some(service_tier); + config.service_tier = Some(service_tier.request_value().to_string()); }); } let harness = TestCodexHarness::with_builder(builder).await?; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b4bbb2c1d5..6e232f7297 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1066,7 +1066,7 @@ fn session_configured_from_thread_start_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1091,7 +1091,7 @@ fn session_configured_from_thread_resume_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy.to_core(), response.approvals_reviewer.to_core(), response @@ -1125,7 +1125,7 @@ fn session_configured_from_thread_response( rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 2e2c664e64..53a10934a5 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -18,7 +18,6 @@ use codex_otel::TelemetryAuthMode; use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::InitialHistory; @@ -46,7 +45,7 @@ pub(crate) struct StageOneRequestContext { pub(crate) session_telemetry: SessionTelemetry, pub(crate) reasoning_effort: Option, pub(crate) reasoning_summary: ReasoningSummary, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) turn_metadata_header: Option, } @@ -194,7 +193,7 @@ impl MemoryStartupContext { &context.session_telemetry, context.reasoning_effort, context.reasoning_summary, - context.service_tier, + context.service_tier.clone(), context.turn_metadata_header.as_deref(), &InferenceTraceContext::disabled(), ) diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index f80b891ddc..4fcb1d409b 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -253,14 +253,18 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu model: None, effort: None, summary: None, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), collaboration_mode: None, personality: None, }) .await?; - let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast)).await?; - assert_eq!(config_snapshot.service_tier, Some(ServiceTier::Fast)); + let config_snapshot = + wait_for_service_tier(&test, Some(ServiceTier::Fast.request_value().to_string())).await?; + assert_eq!( + config_snapshot.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); let context = crate::runtime::MemoryStartupContext::new( Arc::clone(&test.thread_manager), @@ -277,7 +281,10 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu ReasoningEffort::Low, ) .await; - assert_eq!(request_context.service_tier, Some(ServiceTier::Fast)); + assert_eq!( + request_context.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); shutdown_test_codex(&test).await?; Ok(()) @@ -394,7 +401,7 @@ async fn wait_for_request(mock: &ResponseMock, expected_count: usize) -> Vec, + expected_service_tier: Option, ) -> anyhow::Result { let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index da83ee858a..47dc15f183 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -355,6 +355,23 @@ pub enum ServiceTier { Flex, } +impl ServiceTier { + pub const fn request_value(self) -> &'static str { + match self { + Self::Fast => "priority", + Self::Flex => "flex", + } + } + + pub fn from_request_value(value: &str) -> Option { + match value { + "fast" | "priority" => Some(Self::Fast), + "flex" => Some(Self::Flex), + _ => None, + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 017eca8c59..4689bbae20 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -22,7 +22,6 @@ use crate::config_types::CollaborationMode; use crate::config_types::ModeKind; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; -use crate::config_types::ServiceTier; use crate::config_types::WindowsSandboxLevel; use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; @@ -514,7 +513,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -575,7 +574,7 @@ pub enum Op { /// explicitly clear the tier for this turn, or `None` to keep the existing /// session preference. #[serde(default, skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, // The JSON schema to use for the final assistant message final_output_json_schema: Option, @@ -652,7 +651,7 @@ pub enum Op { /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the /// preference, or `None` to leave the existing value unchanged. #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, + service_tier: Option>, /// EXPERIMENTAL - set a pre-set collaboration mode. /// Takes precedence over model, effort, and developer instructions if set. @@ -3476,7 +3475,7 @@ pub struct SessionConfiguredEvent { pub model_provider_id: String, #[serde(skip_serializing_if = "Option::is_none")] - pub service_tier: Option, + pub service_tier: Option, /// When to escalate for approval for execution pub approval_policy: AskForApproval, @@ -3542,7 +3541,7 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { thread_name: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, #[serde(default)] approvals_reviewer: ApprovalsReviewer, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 37fcdb4251..ad174127b9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1263,7 +1263,8 @@ impl App { AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); let profile = self.active_profile.as_deref(); - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); let mut edits = ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_service_tier(service_tier); diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 05aba144c8..b114d08be9 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -617,7 +617,10 @@ impl App { pub(super) fn fresh_session_config(&self) -> Config { let mut config = self.config.clone(); - config.service_tier = self.chat_widget.configured_service_tier(); + config.service_tier = self + .chat_widget + .configured_service_tier() + .map(|service_tier| service_tier.request_value().to_string()); config.notices.fast_default_opt_out = self.chat_widget.fast_default_opt_out(); config } diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8598c035a9..e926da7ecc 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -4496,7 +4496,11 @@ async fn fresh_session_config_uses_current_service_tier() { assert_eq!( config.service_tier, - Some(codex_protocol::config_types::ServiceTier::Fast) + Some( + codex_protocol::config_types::ServiceTier::Fast + .request_value() + .to_string() + ) ); } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 1915cc7683..37712857c5 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -623,7 +623,7 @@ impl App { model.to_string(), *effort, *summary, - *service_tier, + service_tier.clone(), collaboration_mode.clone(), *personality, final_output_json_schema.clone(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 15d034cc3d..33837ea12f 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -63,7 +63,10 @@ impl App { thread_name: None, model: self.chat_widget.current_model().to_string(), model_provider_id: self.config.model_provider_id.clone(), - service_tier: self.chat_widget.current_service_tier(), + service_tier: self + .chat_widget + .current_service_tier() + .map(|service_tier| service_tier.request_value().to_string()), approval_policy: AskForApproval::from( self.config.permissions.approval_policy.value(), ), diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 8633da04bf..cfce49ce28 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -15,7 +15,6 @@ use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -46,7 +45,7 @@ pub(crate) enum AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -60,7 +59,7 @@ pub(crate) enum AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, }, @@ -154,7 +153,7 @@ impl AppCommand { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, final_output_json_schema: Option, collaboration_mode: Option, personality: Option, @@ -185,7 +184,7 @@ impl AppCommand { model: Option, effort: Option>, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, ) -> Self { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 16f2f05d0c..7d5e06451c 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -531,7 +531,7 @@ impl AppServerSession { model: String, effort: Option, summary: Option, - service_tier: Option>, + service_tier: Option>, collaboration_mode: Option, personality: Option, output_schema: Option, @@ -1344,7 +1344,7 @@ async fn thread_session_state_from_thread_start_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1376,7 +1376,7 @@ async fn thread_session_state_from_thread_resume_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1408,7 +1408,7 @@ async fn thread_session_state_from_thread_fork_response( response.thread.path.clone(), response.model.clone(), response.model_provider.clone(), - response.service_tier, + response.service_tier.clone(), response.approval_policy, response.approvals_reviewer.to_core(), permission_profile, @@ -1450,7 +1450,7 @@ async fn thread_session_state_from_thread_response( rollout_path: Option, model: String, model_provider_id: String, - service_tier: Option, + service_tier: Option, approval_policy: AskForApproval, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, permission_profile: PermissionProfile, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 377ebf3a9e..3cee9d2152 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2066,7 +2066,10 @@ impl ChatWidget { self.current_rollout_path = session.rollout_path.clone(); self.current_cwd = Some(session.cwd.to_path_buf()); self.config.cwd = session.cwd.clone(); - self.effective_service_tier = session.service_tier; + self.effective_service_tier = session + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); if let Err(err) = self .config .permissions @@ -2115,7 +2118,7 @@ impl ChatWidget { if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = - self.should_show_fast_status(&model_for_header, session.service_tier); + self.should_show_fast_status(&model_for_header, self.effective_service_tier); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -4880,7 +4883,10 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); let current_cwd = Some(config.cwd.to_path_buf()); - let effective_service_tier = config.service_tier; + let effective_service_tier = config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let current_terminal_info = terminal_info(); let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); @@ -5865,7 +5871,7 @@ impl ChatWidget { .personality .filter(|_| self.config.features.enabled(Feature::Personality)) .filter(|_| self.current_model_supports_personality()); - let service_tier = match self.config.service_tier { + let service_tier = match self.config.service_tier.clone() { Some(service_tier) => Some(Some(service_tier)), None if self.config.notices.fast_default_opt_out == Some(true) => Some(None), None => None, @@ -9277,7 +9283,8 @@ impl ChatWidget { /// Set Fast mode in the widget's config copy. pub(crate) fn set_service_tier(&mut self, service_tier: Option) { - self.config.service_tier = service_tier; + self.config.service_tier = + service_tier.map(|service_tier| service_tier.request_value().to_string()); self.effective_service_tier = service_tier; } @@ -9286,7 +9293,10 @@ impl ChatWidget { } pub(crate) fn configured_service_tier(&self) -> Option { - self.config.service_tier + self.config + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value) } pub(crate) fn fast_default_opt_out(&self) -> Option { @@ -9393,7 +9403,7 @@ impl ChatWidget { /*model*/ None, /*effort*/ None, /*summary*/ None, - Some(service_tier), + Some(service_tier.map(|service_tier| service_tier.request_value().to_string())), /*collaboration_mode*/ None, /*personality*/ None, ))); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 1db968f7e0..0d781a6a38 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -182,7 +182,10 @@ pub(super) async fn make_chatwidget_manual( }; let current_collaboration_mode = base_mode; let active_collaboration_mask = collaboration_modes::default_mask(model_catalog.as_ref()); - let effective_service_tier = cfg.service_tier; + let effective_service_tier = cfg + .service_tier + .as_deref() + .and_then(ServiceTier::from_request_value); let mut widget = ChatWidget { app_event_tx, codex_op_target: super::CodexOpTarget::Direct(op_tx), diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 3b6b0e7ff2..1f03835511 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1803,9 +1803,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1834,9 +1834,9 @@ async fn fast_keybinding_toggle_uses_same_events_as_fast_slash_command() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected fast-mode override app event; events: {events:?}" ); @@ -1884,9 +1884,9 @@ async fn user_turn_carries_service_tier_after_fast_toggle() { match next_submit_op(&mut op_rx) { Op::UserTurn { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => {} + } if service_tier == ServiceTier::Fast.request_value() => {} other => panic!("expected Op::UserTurn with fast service tier, got {other:?}"), } } @@ -1909,9 +1909,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { events.iter().any(|event| matches!( event, AppEvent::CodexOp(Op::OverrideTurnContext { - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - }) + }) if service_tier == ServiceTier::Fast.request_value() )), "expected queued /fast to update service tier before next turn; events: {events:?}" ); @@ -1919,9 +1919,9 @@ async fn queued_fast_slash_applies_before_next_queued_message() { match next_submit_op(&mut op_rx) { Op::UserTurn { items, - service_tier: Some(Some(ServiceTier::Fast)), + service_tier: Some(Some(service_tier)), .. - } => assert_eq!( + } if service_tier == ServiceTier::Fast.request_value() => assert_eq!( items, vec![UserInput::Text { text: "hello after fast".to_string(), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index ec0f7789d7..684a43e5f2 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -25,7 +25,7 @@ pub(crate) struct ThreadSessionState { pub(crate) thread_name: Option, pub(crate) model: String, pub(crate) model_provider_id: String, - pub(crate) service_tier: Option, + pub(crate) service_tier: Option, pub(crate) approval_policy: AskForApproval, pub(crate) approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer, /// Canonical active permissions for this session. Legacy app-server From 2004173cd761b9d6a8ada9c8597dd93b22b4b5a3 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 6 May 2026 08:35:42 -0700 Subject: [PATCH 19/30] Move message history out of core (#21278) ## Why Message history was implemented inside `codex-core` and surfaced through core protocol ops and `SessionConfiguredEvent` fields even though the current consumer is TUI-local prompt recall. That made core own UI history persistence and exposed `history_log_id` / `history_entry_count` through surfaces that app-server and other clients do not need. This change moves message history persistence out of core and keeps the recall plumbing local to the TUI. ## What changed - Added a new `codex-message-history` crate for appending, looking up, trimming, and reading metadata from `history.jsonl`. - Removed core protocol history ops/events: `AddToHistory`, `GetHistoryEntryRequest`, and `GetHistoryEntryResponse`. - Removed `history_log_id` and `history_entry_count` from `SessionConfiguredEvent` and updated exec/MCP/test fixtures accordingly. - Updated the TUI to dispatch local app events for message-history append/lookup and keep its persistent-history metadata in TUI session state. ## Validation - `cargo test -p codex-message-history -p codex-protocol` - `cargo test -p codex-exec event_processor_with_json_output` - `cargo test -p codex-mcp-server outgoing_message` - `cargo test -p codex-tui` - `just fix -p codex-message-history -p codex-protocol -p codex-core -p codex-tui -p codex-exec -p codex-mcp-server` --- codex-rs/Cargo.lock | 14 ++ codex-rs/Cargo.toml | 1 + codex-rs/app-server-client/src/lib.rs | 3 - codex-rs/core/src/lib.rs | 5 - codex-rs/core/src/session/handlers.rs | 55 ------- codex-rs/core/src/session/session.rs | 22 +-- codex-rs/core/src/session/turn.rs | 1 - codex-rs/exec/src/lib.rs | 2 - .../tests/event_processor_with_json_output.rs | 2 - codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/mcp-server/src/outgoing_message.rs | 10 -- codex-rs/message-history/BUILD.bazel | 6 + codex-rs/message-history/Cargo.toml | 24 +++ .../src/lib.rs} | 55 ++++--- .../src/tests.rs} | 37 ++--- codex-rs/protocol/src/lib.rs | 1 - codex-rs/protocol/src/message_history.rs | 11 -- codex-rs/protocol/src/protocol.rs | 43 ------ codex-rs/rollout-trace/src/protocol_event.rs | 2 - codex-rs/rollout/src/policy.rs | 1 - codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 2 - codex-rs/tui/src/app/config_persistence.rs | 3 +- codex-rs/tui/src/app/event_dispatch.rs | 11 ++ codex-rs/tui/src/app/tests.rs | 32 +--- codex-rs/tui/src/app/thread_events.rs | 3 +- codex-rs/tui/src/app/thread_routing.rs | 116 ++++++-------- codex-rs/tui/src/app/thread_session_state.rs | 9 +- codex-rs/tui/src/app_command.rs | 15 -- codex-rs/tui/src/app_event.rs | 16 +- codex-rs/tui/src/app_server_session.rs | 29 ++-- codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 +- .../src/bottom_pane/chat_composer_history.rs | 145 ++++++++++-------- codex-rs/tui/src/bottom_pane/mod.rs | 11 +- codex-rs/tui/src/chatwidget.rs | 25 ++- .../chatwidget/tests/composer_submission.rs | 27 ++-- .../tui/src/chatwidget/tests/exec_flow.rs | 7 +- .../src/chatwidget/tests/history_replay.rs | 27 ++-- .../tui/src/chatwidget/tests/permissions.rs | 6 +- .../tui/src/chatwidget/tests/plan_mode.rs | 6 +- .../src/chatwidget/tests/slash_commands.rs | 49 +++--- .../src/chatwidget/tests/status_and_layout.rs | 3 +- codex-rs/tui/src/history_cell.rs | 3 +- codex-rs/tui/src/session_state.rs | 9 +- codex-rs/tui/tests/fixtures/oss-story.jsonl | 2 +- 45 files changed, 375 insertions(+), 492 deletions(-) create mode 100644 codex-rs/message-history/BUILD.bazel create mode 100644 codex-rs/message-history/Cargo.toml rename codex-rs/{core/src/message_history.rs => message-history/src/lib.rs} (90%) rename codex-rs/{core/src/message_history_tests.rs => message-history/src/tests.rs} (88%) delete mode 100644 codex-rs/protocol/src/message_history.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6359089371..8419848ecd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3132,6 +3132,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-model-provider" version = "0.0.0" @@ -3674,6 +3687,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-message-history", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ba20cfb906..f1673d3bab 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -172,6 +172,7 @@ codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-message-history = { path = "message-history" } codex-memories-mcp = { path = "memories/mcp" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index a22eed1c80..6e10cef255 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -72,12 +72,9 @@ pub mod legacy_core { pub use codex_core::DEFAULT_AGENTS_MD_FILENAME; pub use codex_core::LOCAL_AGENTS_MD_FILENAME; pub use codex_core::McpManager; - pub use codex_core::append_message_history_entry; pub use codex_core::check_execpolicy_for_warnings; pub use codex_core::format_exec_policy_error_with_source; pub use codex_core::grant_read_root_non_elevated; - pub use codex_core::lookup_message_history_entry; - pub use codex_core::message_history_metadata; pub use codex_core::web_search_detail; pub mod config { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4cdfc5ea23..1a754d1ece 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -61,14 +61,9 @@ pub use codex_mcp::SandboxState; mod mcp_openai_file; mod mcp_tool_call; pub(crate) mod mention_syntax; -pub(crate) mod message_history; pub(crate) mod utils; pub use mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; pub use mention_syntax::TOOL_MENTION_SIGIL; -pub use message_history::HistoryEntry as MessageHistoryEntry; -pub use message_history::append_entry as append_message_history_entry; -pub use message_history::history_metadata as message_history_metadata; -pub use message_history::lookup as lookup_message_history_entry; pub use utils::path_utils; pub mod personality_migration; pub(crate) mod plugins; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index d2ba747aff..b57798e5c2 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -460,53 +460,6 @@ pub async fn dynamic_tool_response(sess: &Arc, id: String, response: Dy sess.notify_dynamic_tool_response(&id, response).await; } -pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { - let id = sess.conversation_id; - let config = Arc::clone(config); - tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { - warn!("failed to append to message history: {e}"); - } - }); -} - -pub async fn get_history_entry_request( - sess: &Arc, - config: &Arc, - sub_id: String, - offset: usize, - log_id: u64, -) { - let config = Arc::clone(config); - let sess_clone = Arc::clone(sess); - - tokio::spawn(async move { - // Run lookup in blocking thread because it does file IO + locking. - let entry_opt = tokio::task::spawn_blocking(move || { - crate::message_history::lookup(log_id, offset, &config) - }) - .await - .unwrap_or(None); - - let event = Event { - id: sub_id, - msg: EventMsg::GetHistoryEntryResponse( - codex_protocol::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry { - conversation_id: e.session_id, - ts: e.ts, - text: e.text, - }), - }, - ), - }; - - sess_clone.send_event_raw(event).await; - }); -} - pub async fn refresh_mcp_servers(sess: &Arc, refresh_config: McpServerRefreshConfig) { let mut guard = sess.pending_mcp_server_refresh_config.lock().await; *guard = Some(refresh_config); @@ -910,14 +863,6 @@ pub(super) async fn submission_loop( dynamic_tool_response(&sess, id, response).await; false } - Op::AddToHistory { text } => { - add_to_history(&sess, &config, text).await; - false - } - Op::GetHistoryEntryRequest { offset, log_id } => { - get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id).await; - false - } Op::ListMcpTools => { list_mcp_tools(&sess, &config, sub.id.clone()).await; false diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 3c6acd31e3..6ed0c3fc1a 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -470,19 +470,6 @@ impl Session { )); let state_db_ctx = if config.ephemeral { None } else { state_db }; - let is_subagent = session_configuration.session_source.is_non_root_agent(); - let history_meta_fut = async { - if is_subagent { - (0, 0) - } else { - crate::message_history::history_metadata(&config).await - } - } - .instrument(info_span!( - "session_init.history_metadata", - otel.name = "session_init.history_metadata", - session_init.is_subagent = is_subagent, - )); let auth_manager_clone = Arc::clone(&auth_manager); let config_for_mcp = Arc::clone(&config); let mcp_manager_for_mcp = Arc::clone(&mcp_manager); @@ -505,11 +492,8 @@ impl Session { )); // Join all independent futures. - let ( - thread_persistence_result, - (history_log_id, history_entry_count), - (auth, mcp_servers, auth_statuses), - ) = tokio::join!(thread_persistence_fut, history_meta_fut, auth_and_mcp_fut); + let (thread_persistence_result, (auth, mcp_servers, auth_statuses)) = + tokio::join!(thread_persistence_fut, auth_and_mcp_fut); let mut live_thread_init = LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| { @@ -920,8 +904,6 @@ impl Session { active_permission_profile: session_configuration.active_permission_profile(), cwd: session_configuration.cwd.clone(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), - history_log_id, - history_entry_count, initial_messages, network_proxy: session_network_proxy.filter(|_| { Self::managed_network_proxy_active_for_permission_profile( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 7126dfd27a..a318239f1f 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1509,7 +1509,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::DeprecationNotice(_) | EventMsg::StreamError(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 6e232f7297..a26dc525e6 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1153,8 +1153,6 @@ fn session_configured_from_thread_response( active_permission_profile, cwd, reasoning_effort, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path, 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 2e6560be94..efda58f412 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -122,8 +122,6 @@ fn session_configured_produces_thread_started_event() { active_permission_profile: None, cwd: test_path_buf("/tmp/project").abs(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 772cae63b9..8917ebf823 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -347,7 +347,6 @@ async fn run_codex_tool_session_inner( | EventMsg::TurnDiff(_) | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index c9ad91bbc6..b2882643ce 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -310,8 +310,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -357,8 +355,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -397,8 +393,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); @@ -428,8 +422,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffort::default()), - history_log_id: 1, - history_entry_count: 1000, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -469,8 +461,6 @@ mod tests { "permission_profile": session_configured_event.permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": session_configured_event.reasoning_effort, - "history_log_id": session_configured_event.history_log_id, - "history_entry_count": session_configured_event.history_entry_count, "rollout_path": rollout_file.path().to_path_buf(), } }); diff --git a/codex-rs/message-history/BUILD.bazel b/codex-rs/message-history/BUILD.bazel new file mode 100644 index 0000000000..70df76cf77 --- /dev/null +++ b/codex-rs/message-history/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "message-history", + crate_name = "codex_message_history", +) diff --git a/codex-rs/message-history/Cargo.toml b/codex-rs/message-history/Cargo.toml new file mode 100644 index 0000000000..34bffd687a --- /dev/null +++ b/codex-rs/message-history/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "codex-message-history" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_message_history" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-config = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-util", "rt"] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/message-history/src/lib.rs similarity index 90% rename from codex-rs/core/src/message_history.rs rename to codex-rs/message-history/src/lib.rs index 3458ec7306..0d85cb8fe3 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/message-history/src/lib.rs @@ -5,16 +5,14 @@ //! JSON-Lines tooling. Each record has the following schema: //! //! ````text -//! {"conversation_id":"","ts":,"text":""} +//! {"session_id":"","ts":,"text":""} //! ```` //! -//! To minimise the chance of interleaved writes when multiple processes are +//! To minimize the chance of interleaved writes when multiple processes are //! appending concurrently, callers should *prepare the full line* (record + //! trailing `\n`) and write it with a **single `write(2)` system call** while //! the file descriptor is opened with the `O_APPEND` flag. POSIX guarantees //! that writes up to `PIPE_BUF` bytes are atomic in that case. -//! Note: `conversation_id` stores the thread id; the field name is preserved for -//! backwards compatibility with existing history files. use std::fs::File; use std::fs::OpenOptions; @@ -26,6 +24,7 @@ use std::io::Seek; use std::io::SeekFrom; use std::io::Write; use std::path::Path; +use std::path::PathBuf; use serde::Deserialize; use serde::Serialize; @@ -34,11 +33,9 @@ use std::time::Duration; use tokio::fs; use tokio::io::AsyncReadExt; -use crate::config::Config; +use codex_config::types::History; use codex_config::types::HistoryPersistence; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_protocol::ThreadId; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; #[cfg(unix)] @@ -60,7 +57,24 @@ pub struct HistoryEntry { pub text: String, } -fn history_filepath(config: &Config) -> AbsolutePathBuf { +#[derive(Debug, Clone, PartialEq)] +pub struct HistoryConfig { + pub codex_home: PathBuf, + pub persistence: HistoryPersistence, + pub max_bytes: Option, +} + +impl HistoryConfig { + pub fn new(codex_home: impl Into, history: &History) -> Self { + Self { + codex_home: codex_home.into(), + persistence: history.persistence, + max_bytes: history.max_bytes, + } + } +} + +fn history_filepath(config: &HistoryConfig) -> PathBuf { config.codex_home.join(HISTORY_FILENAME) } @@ -79,8 +93,12 @@ fn history_filepath(config: &Config) -> AbsolutePathBuf { /// Returns an I/O error if the history file cannot be opened/created, the /// system clock is before the Unix epoch, or the exclusive lock cannot be /// acquired after [`MAX_RETRIES`] attempts. -pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Config) -> Result<()> { - match config.history.persistence { +pub async fn append_entry( + text: &str, + conversation_id: impl std::fmt::Display, + config: &HistoryConfig, +) -> Result<()> { + match config.persistence { HistoryPersistence::SaveAll => { // Save everything: proceed. } @@ -128,7 +146,7 @@ pub async fn append_entry(text: &str, conversation_id: &ThreadId, config: &Confi // Ensure permissions. ensure_owner_only_permissions(&history_file).await?; - let history_max_bytes = config.history.max_bytes; + let history_max_bytes = config.max_bytes; // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { @@ -256,7 +274,7 @@ fn trim_target_bytes(max_bytes: u64, newest_entry_len: u64) -> u64 { /// `(0, 0)` when the file does not exist or its metadata cannot be read. If /// metadata succeeds but the file cannot be opened or scanned, returns /// `(log_id, 0)` so callers can still detect that a history file exists. -pub async fn history_metadata(config: &Config) -> (u64, usize) { +pub async fn history_metadata(config: &HistoryConfig) -> (u64, usize) { let path = history_filepath(config); history_metadata_for_file(&path).await } @@ -271,7 +289,7 @@ pub async fn history_metadata(config: &Config) -> (u64, usize) { /// This function is synchronous because it acquires a shared advisory file lock /// via `File::try_lock_shared`. Callers on an async runtime should wrap it in /// `spawn_blocking`. -pub fn lookup(log_id: u64, offset: usize, config: &Config) -> Option { +pub fn lookup(log_id: u64, offset: usize, config: &HistoryConfig) -> Option { let path = history_filepath(config); lookup_history_entry(&path, log_id, offset) } @@ -300,7 +318,7 @@ async fn ensure_owner_only_permissions(_file: &File) -> Result<()> { async fn history_metadata_for_file(path: &Path) -> (u64, usize) { let log_id = match fs::metadata(path).await { - Ok(metadata) => history_log_id(&metadata).unwrap_or(0), + Ok(metadata) => log_identity(&metadata).unwrap_or(0), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return (0, 0), Err(_) => return (0, 0), }; @@ -347,7 +365,7 @@ fn lookup_history_entry(path: &Path, log_id: u64, offset: usize) -> Option Option Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::unix::fs::MetadataExt; Some(metadata.ino()) } #[cfg(windows)] -fn history_log_id(metadata: &std::fs::Metadata) -> Option { +fn log_identity(metadata: &std::fs::Metadata) -> Option { use std::os::windows::fs::MetadataExt; Some(metadata.creation_time()) } #[cfg(not(any(unix, windows)))] -fn history_log_id(_metadata: &std::fs::Metadata) -> Option { +fn log_identity(_metadata: &std::fs::Metadata) -> Option { None } #[cfg(test)] -#[path = "message_history_tests.rs"] mod tests; diff --git a/codex-rs/core/src/message_history_tests.rs b/codex-rs/message-history/src/tests.rs similarity index 88% rename from codex-rs/core/src/message_history_tests.rs rename to codex-rs/message-history/src/tests.rs index de89a3eb9c..88f0b7e007 100644 --- a/codex-rs/core/src/message_history_tests.rs +++ b/codex-rs/message-history/src/tests.rs @@ -1,6 +1,5 @@ use super::*; -use crate::config::ConfigBuilder; -use codex_protocol::ThreadId; +use codex_config::types::History; use pretty_assertions::assert_eq; use std::fs::File; use std::io::Write; @@ -88,14 +87,9 @@ async fn lookup_uses_stable_log_id_after_appends() { #[tokio::test] async fn append_entry_trims_history_when_beyond_max_bytes() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let entry_one = "a".repeat(200); let entry_two = "b".repeat(200); @@ -109,8 +103,8 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { let first_len = std::fs::metadata(&history_path).expect("metadata").len(); let limit_bytes = first_len + 10; - config.history.max_bytes = - Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + history.max_bytes = Some(usize::try_from(limit_bytes).expect("limit should fit into usize")); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&entry_two, &conversation_id, &config) .await @@ -135,14 +129,9 @@ async fn append_entry_trims_history_when_beyond_max_bytes() { #[tokio::test] async fn append_entry_trims_history_to_soft_cap() { let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .build() - .await - .expect("load config"); - - let conversation_id = ThreadId::new(); + let mut history = History::default(); + let mut config = HistoryConfig::new(codex_home.path(), &history); + let conversation_id = "conversation-id"; let short_entry = "a".repeat(200); let long_entry = "b".repeat(400); @@ -165,10 +154,11 @@ async fn append_entry_trims_history_to_soft_cap() { .checked_sub(short_entry_len) .expect("second entry length should be larger than first entry length"); - config.history.max_bytes = Some( + history.max_bytes = Some( usize::try_from((2 * long_entry_len) + (short_entry_len / 2)) .expect("max bytes should fit into usize"), ); + config = HistoryConfig::new(codex_home.path(), &history); append_entry(&long_entry, &conversation_id, &config) .await @@ -185,10 +175,7 @@ async fn append_entry_trims_history_to_soft_cap() { assert_eq!(entries[0].text, long_entry); let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); - let max_bytes = config - .history - .max_bytes - .expect("max bytes should be configured") as u64; + let max_bytes = config.max_bytes.expect("max bytes should be configured") as u64; assert!(pruned_len <= max_bytes); diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index a945b1a927..56248fd27e 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -16,7 +16,6 @@ pub mod exec_output; pub mod items; pub mod mcp; pub mod memory_citation; -pub mod message_history; pub mod models; pub mod network_policy; pub mod num_format; diff --git a/codex-rs/protocol/src/message_history.rs b/codex-rs/protocol/src/message_history.rs deleted file mode 100644 index 0d8bd8df4e..0000000000 --- a/codex-rs/protocol/src/message_history.rs +++ /dev/null @@ -1,11 +0,0 @@ -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; -use ts_rs::TS; - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -pub struct HistoryEntry { - pub conversation_id: String, - pub ts: u64, - pub text: String, -} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 4689bbae20..efa4554ebd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -34,7 +34,6 @@ use crate::mcp::Resource as McpResource; use crate::mcp::ResourceTemplate as McpResourceTemplate; use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; -use crate::message_history::HistoryEntry; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; use crate::models::ContentItem; @@ -723,18 +722,6 @@ pub enum Op { response: DynamicToolResponse, }, - /// Append an entry to the persistent cross-session message history. - /// - /// Note the entry is not guaranteed to be logged if the user has - /// history disabled, it matches the list of "sensitive" patterns, etc. - AddToHistory { - /// The message text to be stored. - text: String, - }, - - /// Request a single history entry identified by `log_id` + `offset`. - GetHistoryEntryRequest { offset: usize, log_id: u64 }, - /// Request the list of MCP tools available across all configured servers. /// Reply is delivered via `EventMsg::McpListToolsResponse`. ListMcpTools, @@ -875,8 +862,6 @@ impl Op { Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", - Self::AddToHistory { .. } => "add_to_history", - Self::GetHistoryEntryRequest { .. } => "get_history_entry_request", Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", @@ -1421,9 +1406,6 @@ pub enum EventMsg { TurnDiff(TurnDiffEvent), - /// Response to GetHistoryEntryRequest. - GetHistoryEntryResponse(GetHistoryEntryResponseEvent), - /// List of MCP tools available to the agent. McpListToolsResponse(McpListToolsResponseEvent), @@ -3265,15 +3247,6 @@ pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct GetHistoryEntryResponseEvent { - pub offset: usize, - pub log_id: u64, - /// The entry at the requested offset, if available and parseable. - #[serde(skip_serializing_if = "Option::is_none")] - pub entry: Option, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. @@ -3503,12 +3476,6 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, - /// Identifier of the history log file (inode on Unix, 0 otherwise). - pub history_log_id: u64, - - /// Current number of entries in the history log. - pub history_entry_count: usize, - /// Optional initial messages (as events) for resumed sessions. /// When present, UIs can use these to seed the history. #[serde(skip_serializing_if = "Option::is_none")] @@ -3554,8 +3521,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: Option, cwd: AbsolutePathBuf, reasoning_effort: Option, - history_log_id: u64, - history_entry_count: usize, initial_messages: Option>, network_proxy: Option, rollout_path: Option, @@ -3588,8 +3553,6 @@ impl<'de> Deserialize<'de> for SessionConfiguredEvent { active_permission_profile: wire.active_permission_profile, cwd: wire.cwd, reasoning_effort: wire.reasoning_effort, - history_log_id: wire.history_log_id, - history_entry_count: wire.history_entry_count, initial_messages: wire.initial_messages, network_proxy: wire.network_proxy, rollout_path: wire.rollout_path, @@ -5319,8 +5282,6 @@ mod tests { active_permission_profile: None, cwd: test_path_buf("/home/user/project").abs(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, initial_messages: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -5340,8 +5301,6 @@ mod tests { "permission_profile": permission_profile, "cwd": test_path_buf("/home/user/project"), "reasoning_effort": "medium", - "history_log_id": 0, - "history_entry_count": 0, "rollout_path": format!("{}", rollout_file.path().display()), } }); @@ -5362,8 +5321,6 @@ mod tests { "type": "read-only" }, "cwd": cwd, - "history_log_id": 0, - "history_entry_count": 0, }); let event: SessionConfiguredEvent = serde_json::from_value(value)?; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 4471474154..91ee3f09a3 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -259,7 +259,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::PatchApplyUpdated(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index a965231ef1..39c865a839 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -154,7 +154,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyUpdated(_) | EventMsg::TurnDiff(_) - | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::McpStartupUpdate(_) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index c5538c02ed..5ef87bd9b9 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,6 +42,7 @@ codex-feedback = { workspace = true } codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-login = { workspace = true } +codex-message-history = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 5e90bb863b..d6d65b04a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -42,13 +42,11 @@ use crate::history_cell::HistoryCell; use crate::history_cell::UpdateAvailableHistoryCell; use crate::key_hint::KeyBindingListExt; use crate::keymap::RuntimeKeymap; -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::edit::ConfigEdit; use crate::legacy_core::config::edit::ConfigEditsBuilder; -use crate::legacy_core::lookup_message_history_entry; #[cfg(target_os = "windows")] use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; use crate::model_catalog::ModelCatalog; diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 69eba70cf9..e50c2782ca 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -648,8 +648,7 @@ mod tests { cwd: next_cwd.clone().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ad174127b9..bfe8dc4b24 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -326,6 +326,17 @@ impl App { AppEvent::CodexOp(op) => { self.submit_active_thread_op(app_server, op).await?; } + AppEvent::AppendMessageHistoryEntry { thread_id, text } => { + self.append_message_history_entry(thread_id, text); + } + AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + } => { + self.lookup_message_history_entry(thread_id, offset, log_id) + .await?; + } AppEvent::ApproveRecentAutoReviewDenial { thread_id, id } => { self.chat_widget .approve_recent_auto_review_denial(thread_id, id); diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e926da7ecc..5ab85887a7 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -550,18 +550,9 @@ async fn history_lookup_response_is_routed_to_requesting_thread() -> Result<()> let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; let thread_id = ThreadId::new(); - let handled = app - .try_handle_local_history_op( - thread_id, - &Op::GetHistoryEntryRequest { - offset: 0, - log_id: 1, - }, - ) + app.lookup_message_history_entry(thread_id, /*offset*/ 0, /*log_id*/ 1) .await?; - assert!(handled); - let app_event = tokio::time::timeout(Duration::from_secs(1), app_event_rx.recv()) .await .expect("history lookup should emit an app event") @@ -3751,8 +3742,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -3997,8 +3987,7 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } @@ -4543,8 +4532,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -4607,8 +4595,7 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -4700,8 +4687,7 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -5099,8 +5085,7 @@ async fn new_session_requests_shutdown_for_previous_conversation() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }; @@ -5221,8 +5206,7 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 759adf9228..56477c18cf 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -354,8 +354,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 37712857c5..1a7986ad31 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -413,10 +413,6 @@ impl App { ) -> Result<()> { crate::session_log::log_outbound_op(&op); - if self.try_handle_local_history_op(thread_id, &op).await? { - return Ok(()); - } - if self .try_resolve_app_server_request(app_server, thread_id, &op) .await? @@ -441,70 +437,57 @@ impl App { Ok(()) } - /// Spawn a background task that fetches MCP server status from the app-server - /// via paginated RPCs, then delivers the result back through - /// `AppEvent::McpInventoryLoaded`. - /// - /// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale - /// result may arrive after the user has moved on. We currently accept that - /// tradeoff because the effect is limited to stale inventory output in history, - /// while request-token invalidation would add cross-cutting async state for a - /// low-severity path. - pub(super) async fn try_handle_local_history_op( + /// Persist prompt text in the local cross-session message history. + pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + tokio::spawn(async move { + if let Err(err) = + codex_message_history::append_entry(&text, thread_id, &history_config).await + { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to append to message history" + ); + } + }); + } + + /// Fetch one local cross-session message history entry for the requesting thread. + pub(super) async fn lookup_message_history_entry( &mut self, thread_id: ThreadId, - op: &AppCommand, - ) -> Result { - match op { - AppCommand::AddToHistory { text } => { - let text = text.to_string(); - let config = self.chat_widget.config_ref().clone(); - tokio::spawn(async move { - if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await - { - tracing::warn!( - thread_id = %thread_id, - error = %err, - "failed to append to message history" - ); - } - }); - Ok(true) - } - AppCommand::GetHistoryEntryRequest { offset, log_id } => { - let config = self.chat_widget.config_ref().clone(); - let app_event_tx = self.app_event_tx.clone(); - let offset = *offset; - let log_id = *log_id; - tokio::spawn(async move { - let entry_opt = tokio::task::spawn_blocking(move || { - lookup_message_history_entry(log_id, offset, &config) - }) - .await - .unwrap_or_else(|err| { - tracing::warn!(error = %err, "history lookup task failed"); - None - }); + offset: usize, + log_id: u64, + ) -> Result<()> { + let history_config = codex_message_history::HistoryConfig::new( + self.chat_widget.config_ref().codex_home.clone(), + &self.chat_widget.config_ref().history, + ); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let entry_opt = tokio::task::spawn_blocking(move || { + codex_message_history::lookup(log_id, offset, &history_config) + }) + .await + .unwrap_or_else(|err| { + tracing::warn!(error = %err, "history lookup task failed"); + None + }); - app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { - thread_id, - event: HistoryLookupResponse { - offset, - log_id, - entry: entry_opt.map(|entry| { - codex_protocol::message_history::HistoryEntry { - conversation_id: entry.session_id, - ts: entry.ts, - text: entry.text, - } - }), - }, - }); - }); - Ok(true) - } - _ => Ok(false), - } + app_event_tx.send(AppEvent::ThreadHistoryEntryResponse { + thread_id, + event: HistoryLookupResponse { + offset, + log_id, + entry: entry_opt.map(|entry| entry.text), + }, + }); + }); + Ok(()) } pub(super) async fn try_submit_active_thread_op_via_app_server( @@ -941,8 +924,7 @@ impl App { } else if rollout_path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session.rollout_path = rollout_path; self.upsert_agent_picker_thread( thread_id, diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 33837ea12f..524d8f3c06 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -76,8 +76,7 @@ impl App { cwd: thread.cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: thread.path.clone(), }); @@ -96,8 +95,7 @@ impl App { } else if thread.path.is_some() { session.model.clear(); } - session.history_log_id = 0; - session.history_entry_count = 0; + session.message_history = None; session } @@ -153,8 +151,7 @@ mod tests { cwd: cwd.abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index cfce49ce28..89fd2600f8 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -103,13 +103,6 @@ pub(crate) enum AppCommand { Review { target: ReviewTarget, }, - AddToHistory { - text: String, - }, - GetHistoryEntryRequest { - offset: usize, - log_id: u64, - }, ApproveGuardianDeniedAction { event: GuardianAssessmentEvent, }, @@ -275,14 +268,6 @@ impl AppCommand { Self::Review { target } } - pub(crate) fn add_to_history(text: String) -> Self { - Self::AddToHistory { text } - } - - pub(crate) fn history_lookup(offset: usize, log_id: u64) -> Self { - Self::GetHistoryEntryRequest { offset, log_id } - } - pub(crate) fn approve_guardian_denied_action(event: GuardianAssessmentEvent) -> Self { Self::ApproveGuardianDeniedAction { event } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index c88ff17115..4ee405f495 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -28,7 +28,6 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; use codex_protocol::ThreadId; -use codex_protocol::message_history::HistoryEntry; use codex_protocol::openai_models::ModelPreset; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_approval_presets::ApprovalPreset; @@ -68,7 +67,7 @@ pub(crate) enum ThreadGoalSetMode { pub(crate) struct HistoryLookupResponse { pub(crate) offset: usize, pub(crate) log_id: u64, - pub(crate) entry: Option, + pub(crate) entry: Option, } impl RealtimeAudioDeviceKind { @@ -150,6 +149,19 @@ pub(crate) enum AppEvent { event: HistoryLookupResponse, }, + /// Persist a submitted prompt in the cross-session message history. + AppendMessageHistoryEntry { + thread_id: ThreadId, + text: String, + }, + + /// Fetch a persistent cross-session message history entry by offset. + LookupMessageHistoryEntry { + thread_id: ThreadId, + offset: usize, + log_id: u64, + }, + /// Start a new session. NewSession, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 7d5e06451c..6a170beec7 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -4,11 +4,9 @@ //! request/response plumbing out of `App` and `ChatWidget`. use crate::bottom_pane::FeedbackAudience; -#[cfg(test)] -use crate::legacy_core::append_message_history_entry; use crate::legacy_core::config::Config; -use crate::legacy_core::message_history_metadata; use crate::permission_compat::legacy_compatible_permission_profile; +use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; use crate::status::StatusAccountDisplay; use crate::status::plan_type_display_name; @@ -1467,8 +1465,9 @@ async fn thread_session_state_from_thread_response( .map(ThreadId::from_string) .transpose() .map_err(|err| format!("forked_from_id is invalid: {err}"))?; - let (history_log_id, history_entry_count) = message_history_metadata(config).await; - let history_entry_count = u64::try_from(history_entry_count).unwrap_or(u64::MAX); + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { thread_id, forked_from_id, @@ -1484,8 +1483,10 @@ async fn thread_session_state_from_thread_response( cwd, instruction_source_paths, reasoning_effort, - history_log_id, - history_entry_count, + message_history: Some(MessageHistoryMetadata { + log_id, + entry_count, + }), network_proxy: None, rollout_path, }) @@ -1960,10 +1961,13 @@ mod tests { let config = build_config(&temp_dir).await; let thread_id = ThreadId::new(); - append_message_history_entry("older", &thread_id, &config) + let history_config = + codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + + codex_message_history::append_entry("older", &thread_id, &history_config) .await .expect("history append should succeed"); - append_message_history_entry("newer", &thread_id, &config) + codex_message_history::append_entry("newer", &thread_id, &history_config) .await .expect("history append should succeed"); @@ -1987,8 +1991,11 @@ mod tests { .await .expect("session should map"); - assert_ne!(session.history_log_id, 0); - assert_eq!(session.history_entry_count, 2); + let metadata = session + .message_history + .expect("session should include message-history metadata"); + assert_ne!(metadata.log_id, 0); + assert_eq!(metadata.entry_count, 2); } #[tokio::test] diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 32e8c092fa..0084e4604f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -207,6 +207,7 @@ use crate::render::RectExt; use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::style::user_message_style; +use codex_protocol::ThreadId; use codex_protocol::models::local_image_label_text; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; @@ -849,10 +850,15 @@ impl ChatComposer { && self.remote_image_urls.is_empty() } - /// Record the history metadata advertised by `SessionConfiguredEvent` so - /// that the composer can navigate cross-session history. - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history.set_metadata(log_id, entry_count); + /// Record local persistent-history metadata so the composer can navigate + /// cross-session history. + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.history.set_metadata(thread_id, log_id, entry_count); } /// Integrate an asynchronous response to an on-demand history lookup. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 52ae811226..6a490e81ec 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -15,11 +15,11 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::app_command::AppCommand as Op; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::MentionBinding; use crate::mention_codec::decode_history_mentions; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; /// A composer history entry that can rehydrate draft state. @@ -105,11 +105,13 @@ impl HistoryEntry { /// the chat composer. This struct is intentionally decoupled from the /// rendering widget so the logic remains isolated and easier to test. pub(crate) struct ChatComposerHistory { - /// Identifier of the history log as reported by `SessionConfiguredEvent`. - history_log_id: Option, + /// Thread that owns persistent lookup responses for this metadata snapshot. + thread_id: Option, + /// Identifier of the persistent history log used for stale lookup rejection. + persistent_log_id: Option, /// Number of entries already present in the persistent cross-session /// history file when the session started. - history_entry_count: usize, + persistent_entry_count: usize, /// Messages submitted by the user *during this UI session* (newest at END). /// Local entries retain full draft state (text elements, image paths, pending pastes, remote image URLs). @@ -216,8 +218,9 @@ impl ChatComposerHistory { /// metadata-free lets the composer reset and reuse this helper across session lifecycles. pub fn new() -> Self { Self { - history_log_id: None, - history_entry_count: 0, + thread_id: None, + persistent_log_id: None, + persistent_entry_count: 0, local_history: Vec::new(), fetched_history: HashMap::new(), history_cursor: None, @@ -231,9 +234,10 @@ impl ChatComposerHistory { /// This clears fetched entries, local entries, navigation cursors, and active search state /// because offsets only make sense within one history log snapshot. Reusing old offsets after a /// log-id change would allow a stale async response to hydrate the wrong prompt. - pub fn set_metadata(&mut self, log_id: u64, entry_count: usize) { - self.history_log_id = Some(log_id); - self.history_entry_count = entry_count; + pub fn set_metadata(&mut self, thread_id: ThreadId, log_id: u64, entry_count: usize) { + self.thread_id = Some(thread_id); + self.persistent_log_id = Some(log_id); + self.persistent_entry_count = entry_count; self.fetched_history.clear(); self.local_history.clear(); self.history_cursor = None; @@ -298,7 +302,7 @@ impl ChatComposerHistory { /// history recall. If callers moved the cursor into the middle of a recalled entry and still /// forced navigation, users would lose normal vertical movement within the draft. pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { - if self.history_entry_count == 0 && self.local_history.is_empty() { + if self.persistent_entry_count == 0 && self.local_history.is_empty() { return false; } @@ -320,11 +324,11 @@ impl ChatComposerHistory { /// Handles Up by moving toward older entries in the combined history space. /// /// Local entries can be returned immediately, while missing persistent entries emit a - /// `GetHistoryEntryRequest` and return `None` until the response arrives. Calling this while + /// `LookupMessageHistoryEntry` and return `None` until the response arrives. Calling this while /// Ctrl+R search is active intentionally exits search traversal. pub fn navigate_up(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -346,7 +350,7 @@ impl ChatComposerHistory { /// search state and resumes normal shell-style browsing. pub fn navigate_down(&mut self, app_event_tx: &AppEventSender) -> Option { self.search = None; - let total_entries = self.history_entry_count + self.local_history.len(); + let total_entries = self.persistent_entry_count + self.local_history.len(); if total_entries == 0 { return None; } @@ -385,7 +389,7 @@ impl ChatComposerHistory { entry: Option, app_event_tx: &AppEventSender, ) -> HistoryEntryResponse { - if self.history_log_id != Some(log_id) { + if self.persistent_log_id != Some(log_id) { return HistoryEntryResponse::Ignored; } @@ -517,7 +521,7 @@ impl ChatComposerHistory { // --------------------------------------------------------------------- fn total_entries(&self) -> usize { - self.history_entry_count + self.local_history.len() + self.persistent_entry_count + self.local_history.len() } fn search_start_offset( @@ -588,8 +592,8 @@ impl ChatComposerHistory { if self.search_matches(&entry) && self.search_result_is_unique(&entry) { return self.search_match(offset, entry); } - } else if offset < self.history_entry_count - && let Some(log_id) = self.history_log_id + } else if offset < self.persistent_entry_count + && let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { if let Some(search) = self.search.as_mut() { search.awaiting = Some(PendingHistorySearch { @@ -598,7 +602,11 @@ impl ChatComposerHistory { boundary_if_exhausted, }); } - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(offset, log_id))); + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset, + log_id, + }); return HistorySearchResult::Pending; } @@ -618,9 +626,9 @@ impl ChatComposerHistory { } fn entry_at_cached_offset(&self, offset: usize) -> Option { - if offset >= self.history_entry_count { + if offset >= self.persistent_entry_count { self.local_history - .get(offset - self.history_entry_count) + .get(offset - self.persistent_entry_count) .cloned() } else { self.fetched_history.get(&offset).cloned() @@ -702,11 +710,11 @@ impl ChatComposerHistory { global_idx: usize, app_event_tx: &AppEventSender, ) -> Option { - if global_idx >= self.history_entry_count { + if global_idx >= self.persistent_entry_count { // Local entry. if let Some(entry) = self .local_history - .get(global_idx - self.history_entry_count) + .get(global_idx - self.persistent_entry_count) .cloned() { self.last_history_text = Some(entry.text.clone()); @@ -715,8 +723,12 @@ impl ChatComposerHistory { } else if let Some(entry) = self.fetched_history.get(&global_idx).cloned() { self.last_history_text = Some(entry.text.clone()); return Some(entry); - } else if let Some(log_id) = self.history_log_id { - app_event_tx.send(AppEvent::CodexOp(Op::history_lookup(global_idx, log_id))); + } else if let (Some(thread_id), Some(log_id)) = (self.thread_id, self.persistent_log_id) { + app_event_tx.send(AppEvent::LookupMessageHistoryEntry { + thread_id, + offset: global_idx, + log_id, + }); } None } @@ -794,6 +806,11 @@ mod tests { use pretty_assertions::assert_eq; use tokio::sync::mpsc::unbounded_channel; + fn test_thread_id() -> ThreadId { + ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") + .expect("thread id should parse") + } + #[test] fn duplicate_submissions_are_not_recorded() { let mut history = ChatComposerHistory::new(); @@ -830,7 +847,8 @@ mod tests { let mut history = ChatComposerHistory::new(); // Pretend there are 3 persistent entries. - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); // First Up should request offset 2 (latest) and await async data. assert!(history.should_handle_navigation("", /*cursor*/ 0)); @@ -838,16 +856,17 @@ mod tests { // Verify that a history lookup request was sent. let event = rx.try_recv().expect("expected AppEvent to be sent"); - let AppEvent::CodexOp(op) = event else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); // Inject the async response. assert_eq!( @@ -865,16 +884,17 @@ mod tests { // Verify second lookup request for offset 1. let event2 = rx.try_recv().expect("expected second event"); - let AppEvent::CodexOp(op) = event2 else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = event2 + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Found(HistoryEntry::new("older".to_string())), @@ -1017,7 +1037,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1090,7 +1110,8 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + let thread_id = test_thread_id(); + history.set_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 3); assert_eq!( HistorySearchResult::Pending, @@ -1101,16 +1122,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected latest lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected latest lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 2, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 2); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Pending), @@ -1121,16 +1143,17 @@ mod tests { &tx ) ); - let AppEvent::CodexOp(op) = rx.try_recv().expect("expected next lookup") else { + let AppEvent::LookupMessageHistoryEntry { + thread_id: response_thread_id, + offset, + log_id, + } = rx.try_recv().expect("expected next lookup") + else { panic!("unexpected event variant"); }; - assert_eq!( - Op::GetHistoryEntryRequest { - log_id: 1, - offset: 1, - }, - op - ); + assert_eq!(response_thread_id, thread_id); + assert_eq!(offset, 1); + assert_eq!(log_id, 1); assert_eq!( HistoryEntryResponse::Search(HistorySearchResult::Found(HistoryEntry::new( @@ -1151,7 +1174,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 4); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 4); assert_eq!( HistorySearchResult::Pending, @@ -1270,7 +1293,7 @@ mod tests { let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); - history.set_metadata(/*log_id*/ 1, /*entry_count*/ 3); + history.set_metadata(test_thread_id(), /*log_id*/ 1, /*entry_count*/ 3); history .fetched_history .insert(1, HistoryEntry::new("command2".to_string())); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 29dbf59427..fac5477924 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -36,6 +36,7 @@ use codex_core_skills::model::SkillMetadata; use codex_features::Features; use codex_file_search::FileMatch; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::ThreadId; use codex_protocol::user_input::TextElement; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1426,8 +1427,14 @@ impl BottomPane { // --- History helpers --- - pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { - self.composer.set_history_metadata(log_id, entry_count); + pub(crate) fn set_history_metadata( + &mut self, + thread_id: ThreadId, + log_id: u64, + entry_count: usize, + ) { + self.composer + .set_history_metadata(thread_id, log_id, entry_count); } pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3cee9d2152..0dbf0d235a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2043,10 +2043,12 @@ impl ChatWidget { self.visible_user_turn_count = 0; self.copy_history_evicted_by_rollback = false; self.saw_copy_source_this_turn = false; - let history_entry_count = - usize::try_from(session.history_entry_count).unwrap_or(usize::MAX); - self.bottom_pane - .set_history_metadata(session.history_log_id, history_entry_count); + let history_metadata = session.message_history.unwrap_or_default(); + self.bottom_pane.set_history_metadata( + session.thread_id, + history_metadata.log_id, + history_metadata.entry_count, + ); self.set_skills(/*skills*/ None); self.session_network_proxy = session.network_proxy.clone(); let previous_thread_id = self.thread_id; @@ -4037,7 +4039,7 @@ impl ChatWidget { entry, } = event; self.bottom_pane - .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); + .on_history_entry_response(log_id, offset, entry); } fn on_shutdown_complete(&mut self) { @@ -5588,7 +5590,7 @@ impl ChatWidget { ) -> QueueDrain { let drain = self.submit_shell_command(command); if drain == QueueDrain::Stop { - self.submit_op(AppCommand::add_to_history(history_text.to_string())); + self.append_message_history_entry(history_text.to_string()); } drain } @@ -5919,7 +5921,7 @@ impl ChatWidget { } }; if let Some(history_text) = history_text { - self.submit_op(AppCommand::add_to_history(history_text)); + self.append_message_history_entry(history_text); } if let Some(pending_steer) = pending_steer { @@ -10550,6 +10552,15 @@ impl ChatWidget { true } + fn append_message_history_entry(&self, text: String) { + let Some(thread_id) = self.thread_id else { + tracing::warn!("failed to append to message history: no active thread id"); + return; + }; + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } + pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { if matches!(op, AppCommand::Interrupt) && self.agent_turn_running { if let Some(controller) = self.stream_controller.as_mut() { diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 0fa4567d9f..d16e031a84 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -29,8 +29,7 @@ async fn submission_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -133,8 +132,7 @@ async fn submission_includes_configured_permission_profile() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -183,8 +181,7 @@ async fn submission_keeps_profile_when_legacy_projection_is_external() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -225,8 +222,7 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -319,8 +315,7 @@ async fn enter_with_only_remote_images_submits_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -383,8 +378,7 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -422,8 +416,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -461,8 +454,7 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -503,8 +495,7 @@ async fn submission_prefers_selected_duplicate_skill_path() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 40ccadb7c3..b1949d7809 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -958,8 +958,7 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -977,8 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { other => panic!("expected RunUserShellCommand op, got {other:?}"), } assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" + rx.try_recv(), + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "!echo hi" ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 93e8e62f07..d3cd753986 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -31,8 +31,7 @@ async fn resumed_initial_messages_render_history() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -102,8 +101,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -171,8 +169,7 @@ async fn replayed_user_message_preserves_remote_image_urls() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -271,8 +268,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { cwd: expected_cwd.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -328,8 +324,7 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { cwd: test_path_buf("/home/user/external").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; @@ -367,8 +362,7 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -422,8 +416,7 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -693,8 +686,7 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); @@ -739,8 +731,7 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }); diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 09595a11ba..df3615c0fd 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -586,8 +586,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure cwd: test_project_path().abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); @@ -634,8 +633,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w cwd, instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), }); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 5fb10b8087..a5dd3d0eb7 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1219,8 +1219,7 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }; @@ -1463,8 +1462,7 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: None, }; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 1f03835511..1778579808 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -38,14 +38,16 @@ fn recall_latest_after_clearing(chat: &mut ChatWidget) -> String { chat.bottom_pane.composer_text() } -fn next_add_to_history_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { +fn next_add_to_history_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> String { loop { - match op_rx.try_recv() { - Ok(Op::AddToHistory { text }) => return text, + match rx.try_recv() { + Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) => return text, Ok(_) => continue, - Err(TryRecvError::Empty) => panic!("expected AddToHistory op but queue was empty"), + Err(TryRecvError::Empty) => { + panic!("expected AppendMessageHistoryEntry event but queue was empty") + } Err(TryRecvError::Disconnected) => { - panic!("expected AddToHistory op but channel closed") + panic!("expected AppendMessageHistoryEntry event but channel closed") } } } @@ -116,15 +118,6 @@ async fn queued_slash_review_with_args_dispatches_after_active_turn() { complete_turn_with_message(&mut chat, "turn-1", Some("done")); match op_rx.try_recv() { - Ok(Op::AddToHistory { .. }) => match op_rx.try_recv() { - Ok(Op::Review { target }) => assert_eq!( - target, - ReviewTarget::Custom { - instructions: "check regressions".to_string(), - } - ), - other => panic!("expected queued /review to submit review op, got {other:?}"), - }, Ok(Op::Review { target }) => assert_eq!( target, ReviewTarget::Custom { @@ -152,7 +145,7 @@ async fn queued_slash_review_with_args_restores_for_edit() { #[tokio::test] async fn queued_bang_shell_dispatches_after_active_turn() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -171,10 +164,7 @@ async fn queued_bang_shell_dispatches_after_active_turn() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert!(chat.queued_user_messages.is_empty()); } @@ -217,7 +207,7 @@ async fn queued_empty_bang_shell_reports_help_when_dequeued_and_drains_next_inpu #[tokio::test] async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -230,10 +220,7 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), other => panic!("expected queued shell command op, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "!echo hi" - ); + assert_eq!(next_add_to_history_event(&mut rx), "!echo hi"); assert_eq!(chat.queued_user_messages.len(), 1); let begin = begin_exec_with_source( @@ -412,10 +399,10 @@ async fn queued_inline_rename_does_not_drain_again_before_turn_started() { ), other => panic!("expected first queued message after /rename, got {other:?}"), } - assert_matches!( - op_rx.try_recv(), - Ok(Op::AddToHistory { text }) if text == "first after rename" - ); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::AppendMessageHistoryEntry { text, .. } if text == "first after rename" + ))); assert_eq!( chat.queued_user_message_texts(), vec!["second after rename"] @@ -945,7 +932,7 @@ fn merged_history_record_remaps_override_image_placeholders() { #[tokio::test] async fn interrupted_merged_message_history_encodes_mentions_once() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.on_task_started(); chat.on_agent_message_delta("Final answer line\n".to_string()); @@ -977,7 +964,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { other => panic!("expected user turn, got {other:?}"), } let encoded = "use [$figma](app://figma) now"; - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); next_interrupt_op(&mut op_rx); @@ -997,7 +984,7 @@ async fn interrupted_merged_message_history_encodes_mentions_once() { } other => panic!("expected resubmitted user turn, got {other:?}"), } - assert_eq!(next_add_to_history_op(&mut op_rx), encoded); + assert_eq!(next_add_to_history_event(&mut rx), encoded); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 96b2b681b6..dcb72ed3b2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1863,8 +1863,7 @@ async fn session_configured_clears_goal_status_footer() { cwd: test_path_buf("/home/user/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a9dd59e794..256345f67a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -3830,8 +3830,7 @@ mod tests { cwd: test_path_buf("/tmp/project").abs(), instruction_source_paths: Vec::new(), reasoning_effort: None, - history_log_id: 0, - history_entry_count: 0, + message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), } diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index 684a43e5f2..e4d2dbab96 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -17,6 +17,12 @@ pub(crate) struct SessionNetworkProxyRuntime { pub(crate) socks_addr: String, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct MessageHistoryMetadata { + pub(crate) log_id: u64, + pub(crate) entry_count: usize, +} + #[derive(Debug, Clone, PartialEq)] pub(crate) struct ThreadSessionState { pub(crate) thread_id: ThreadId, @@ -38,8 +44,7 @@ pub(crate) struct ThreadSessionState { pub(crate) cwd: AbsolutePathBuf, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, - pub(crate) history_log_id: u64, - pub(crate) history_entry_count: u64, + pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option, } diff --git a/codex-rs/tui/tests/fixtures/oss-story.jsonl b/codex-rs/tui/tests/fixtures/oss-story.jsonl index 72d0fc40f4..a62182f487 100644 --- a/codex-rs/tui/tests/fixtures/oss-story.jsonl +++ b/codex-rs/tui/tests/fixtures/oss-story.jsonl @@ -2,7 +2,7 @@ {"ts":"2025-08-10T03:12:26.500Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] resume_path: None"} {"ts":"2025-08-10T03:12:26.502Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} -{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b","history_log_id":2532619,"history_entry_count":355}}} +{"ts":"2025-08-10T03:12:26.519Z","dir":"to_tui","kind":"codex_event","payload":{"id":"0","msg":{"type":"session_configured","session_id":"8f7c4ac2-6141-42da-b4d5-7032a8e8df3b","model":"gpt-oss:20b"}}} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"insert_history","lines":9} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-10T03:12:26.520Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} From 2070d5bfd31e49102263df39076fef64aae9922c Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 6 May 2026 09:58:46 -0700 Subject: [PATCH 20/30] [codex] Add response.processed websocket request (#21284) ## Summary - Add a `response.processed` websocket request payload and sender for Responses API websockets. - Send `response.processed` from `try_run_sampling_request` after a response completes, local turn processing succeeds, and the session-owned feature flag is enabled. - Add websocket coverage for both enabled and disabled feature-flag behavior. ## Validation - `just fmt` - `cargo test -p codex-core response_processed` - `cargo test -p codex-api responses_websocket` - `cargo test -p codex-features responses_websocket_response_processed_is_under_development` - `git diff --check` - `just fix -p codex-api -p codex-core -p codex-features` - `git diff --check origin/main...HEAD` --- codex-rs/codex-api/src/common.rs | 7 ++ .../src/endpoint/responses_websocket.rs | 110 +++++++++++++----- codex-rs/codex-api/src/lib.rs | 1 + codex-rs/core/config.schema.json | 6 + codex-rs/core/src/client.rs | 13 +++ codex-rs/core/src/session/turn.rs | 13 ++- .../core/tests/suite/client_websockets.rs | 87 ++++++++++++++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/features/src/tests.rs | 16 +++ 9 files changed, 232 insertions(+), 29 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 50ac2685b4..91b251c41f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -239,6 +239,11 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } +#[derive(Debug, Serialize)] +pub struct ResponseProcessedWsRequest { + pub response_id: String, +} + pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -267,6 +272,8 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.processed")] + ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index dd947a9dd7..c5a682b328 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,5 +1,6 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; +use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -204,6 +205,40 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } + #[instrument( + name = "responses_websocket.send_response_processed", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket while sending a request frame" + )] + pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { + let request = + ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let mut guard = self.stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + return Err(ApiError::Stream( + "websocket connection is closed".to_string(), + )); + }; + + send_websocket_request( + ws_stream, + request_body, + self.idle_timeout, + self.telemetry.as_ref(), + /*connection_reused*/ true, + ) + .await + } + #[instrument( name = "responses_websocket.stream_request", level = "info", @@ -545,36 +580,14 @@ async fn run_websocket_response_stream( connection_reused: bool, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; - let request_text = match serde_json::to_string(&request_body) { - Ok(text) => text, - Err(err) => { - return Err(ApiError::Stream(format!( - "failed to encode websocket request: {err}" - ))); - } - }; - trace!("websocket request: {request_text}"); - - let request_start = Instant::now(); - let result = tokio::time::timeout( + send_websocket_request( + ws_stream, + request_body, idle_timeout, - ws_stream.send(Message::Text(request_text.into())), + telemetry.as_ref(), + connection_reused, ) - .await - .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) - .and_then(|result| { - result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) - }); - - if let Some(t) = telemetry.as_ref() { - t.on_ws_request( - request_start.elapsed(), - result.as_ref().err(), - connection_reused, - ); - } - - result?; + .await?; loop { let poll_start = Instant::now(); @@ -671,6 +684,47 @@ async fn run_websocket_response_stream( Ok(()) } +async fn send_websocket_request( + ws_stream: &WsStream, + request_body: Value, + idle_timeout: Duration, + telemetry: Option<&Arc>, + connection_reused: bool, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + trace!("websocket request: {request_text}"); + + let request_start = Instant::now(); + let result = tokio::time::timeout( + idle_timeout, + ws_stream.send(Message::Text(request_text.into())), + ) + .await + .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) + .and_then(|result| { + result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) + }); + + if let Some(t) = telemetry.as_ref() { + t.on_ws_request( + request_start.elapsed(), + result.as_ref().err(), + connection_reused, + ); + } + + result?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 6913c6e8ca..e6f097db38 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -30,6 +30,7 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; +pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 9f74b8b7cf..17343ed86f 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -523,6 +523,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -4078,6 +4081,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 4806633a3f..39e6e85e20 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -100,6 +100,7 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; +use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; @@ -902,6 +903,18 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } + pub(crate) async fn send_response_processed(&self, response_id: &str) { + let Some(connection) = self.websocket_session.connection.as_ref() else { + return; + }; + if let Err(err) = connection + .send_response_processed(response_id.to_string()) + .await + { + debug!("failed to send response.processed websocket request: {err}"); + } + } + #[allow(clippy::too_many_arguments)] /// Builds shared Responses API transport options and request-body options. /// diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a318239f1f..fb2d9caeb7 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1884,6 +1884,7 @@ async fn try_run_sampling_request( let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode); let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id)); let receiving_span = trace_span!("receiving_stream"); + let mut completed_response_id: Option = None; let outcome: CodexResult = loop { let handle_responses = trace_span!( parent: &receiving_span, @@ -2111,7 +2112,7 @@ async fn try_run_sampling_request( sess.services.models_manager.refresh_if_new_etag(etag).await; } ResponseEvent::Completed { - response_id: _, + response_id, token_usage, end_turn, } => { @@ -2128,6 +2129,7 @@ async fn try_run_sampling_request( if let Some(false) = end_turn { needs_follow_up = true; } + completed_response_id = Some(response_id); break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, @@ -2239,6 +2241,15 @@ async fn try_run_sampling_request( ) .await; + if sess + .features + .enabled(Feature::ResponsesWebsocketResponseProcessed) + && outcome.is_ok() + && let Some(response_id) = completed_response_id.as_deref() + { + client_session.send_response_processed(response_id).await; + } + drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?; if cancellation_token.is_cancelled() { diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 8680ee530a..796b85ca5e 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -171,6 +171,93 @@ async fn responses_websocket_streams_without_feature_flag_when_provider_supports server.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_sends_response_processed_when_feature_enabled() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::ResponsesWebsocketResponseProcessed) + .expect("test config should allow feature update"); + }); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should send response.processed after processing"); + + let processed = server + .wait_for_request(/*connection_index*/ 0, /*request_index*/ 2) + .await; + assert_eq!( + processed.body_json(), + json!({ + "type": "response.processed", + "response_id": "resp-1", + }) + ); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + assert_eq!( + connection[1].body_json()["type"].as_str(), + Some("response.create") + ); + + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_websocket_omits_response_processed_without_feature() { + skip_if_no_network!(); + + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hi"), + ev_completed("resp-1"), + ], + vec![], + ]]) + .await; + let mut builder = test_codex(); + let test = builder + .build_with_websocket_server(&server) + .await + .expect("build websocket codex"); + + test.submit_turn("hello") + .await + .expect("submission should complete without response.processed"); + + let connection = server.single_connection(); + assert_eq!(connection.len(), 2); + + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_websocket_reuses_connection_with_per_turn_trace_payloads() { skip_if_no_network!(); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 19605cd40b..6c8ce49894 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -231,6 +231,8 @@ pub enum Feature { ResponsesWebsockets, /// Legacy rollout flag for Responses API WebSocket transport v2 experiments. ResponsesWebsocketsV2, + /// Send `response.processed` over Responses API websockets after a turn response is recorded. + ResponsesWebsocketResponseProcessed, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Enable workspace dependency support. @@ -1139,6 +1141,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::ResponsesWebsocketResponseProcessed, + key: "responses_websocket_response_processed", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 4ba6b76ce8..5464fa7a61 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -129,6 +129,22 @@ fn remote_compaction_v2_is_under_development() { ); } +#[test] +fn responses_websocket_response_processed_is_under_development() { + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.stage(), + Stage::UnderDevelopment + ); + assert_eq!( + Feature::ResponsesWebsocketResponseProcessed.default_enabled(), + false + ); + assert_eq!( + feature_for_key("responses_websocket_response_processed"), + Some(Feature::ResponsesWebsocketResponseProcessed) + ); +} + #[test] fn builtin_mcp_is_under_development() { assert_eq!(Feature::BuiltInMcp.stage(), Stage::UnderDevelopment); From 0e821b380a012a14f3dac3c729d17e20efeb63c3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 19:32:24 +0200 Subject: [PATCH 21/30] rollout: coalesce thread updated_at touches (#21367) ## Why Metadata-irrelevant rollout events currently refresh `threads.updated_at` on every flush. That keeps thread recency accurate, but it also turns high-frequency agent output into unnecessary SQLite writes. Recency only needs to advance periodically during an active session, while the final suppressed touch still needs to be persisted before shutdown. ## What changed - coalesce touch-only `updated_at` writes in the rollout writer, with a short production interval between persisted touches - retain the latest suppressed touch and flush it during shutdown so the thread is not left stale - extend rollout recorder coverage for coalesced touches, delayed refresh, shutdown flushing, and the existing missing-thread fallback path ## Verification - Added regression coverage in `rollout/src/recorder_tests.rs` for coalescing and shutdown flushing behavior. --------- Co-authored-by: Codex --- codex-rs/rollout/src/recorder.rs | 56 +++++++++++- codex-rs/rollout/src/recorder_tests.rs | 114 ++++++++++++++++++++++++- 2 files changed, 165 insertions(+), 5 deletions(-) diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 7a5e28a5a3..6b549b745c 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -8,7 +8,10 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; +use std::time::Duration; +use std::time::Instant; +use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_protocol::ThreadId; @@ -1430,9 +1433,28 @@ struct RolloutWriterState { state_builder: Option, default_provider: String, generate_memories: bool, + thread_updated_at_touch: ThreadUpdatedAtTouch, last_logged_error: Option, } +#[cfg(not(test))] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_secs(5); +#[cfg(test)] +const THREAD_UPDATED_AT_TOUCH_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Default)] +struct ThreadUpdatedAtTouch { + last_persisted_at: Option, + pending_touch: Option<(ThreadId, DateTime)>, +} + +impl ThreadUpdatedAtTouch { + fn mark_persisted(&mut self, now: Instant) { + self.last_persisted_at = Some(now); + self.pending_touch = None; + } +} + impl RolloutWriterState { #[allow(clippy::too_many_arguments)] fn new( @@ -1460,6 +1482,7 @@ impl RolloutWriterState { state_builder, default_provider, generate_memories, + thread_updated_at_touch: ThreadUpdatedAtTouch::default(), last_logged_error: None, } } @@ -1492,7 +1515,19 @@ impl RolloutWriterState { if self.is_deferred() && self.pending_items.is_empty() { return Ok(()); } - self.write_pending_with_recovery("shutdown").await + self.write_pending_with_recovery("shutdown").await?; + if let Some((thread_id, updated_at)) = self.thread_updated_at_touch.pending_touch.take() + && state_db::touch_thread_updated_at( + self.state_db_ctx.as_deref(), + Some(thread_id), + updated_at, + "rollout_writer_shutdown", + ) + .await + { + self.thread_updated_at_touch.mark_persisted(Instant::now()); + } + Ok(()) } async fn write_pending_with_recovery(&mut self, operation: &str) -> std::io::Result<()> { @@ -1569,6 +1604,7 @@ impl RolloutWriterState { &mut self.state_builder, self.default_provider.as_str(), self.generate_memories, + &mut self.thread_updated_at_touch, ) .await?; self.meta = None; @@ -1612,6 +1648,7 @@ impl RolloutWriterState { written_items.as_slice(), self.default_provider.as_str(), /*new_thread_memory_mode*/ None, + &mut self.thread_updated_at_touch, ) .await; } @@ -1683,6 +1720,7 @@ async fn write_session_meta( state_builder: &mut Option, default_provider: &str, generate_memories: bool, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) -> std::io::Result<()> { let git_info = collect_git_info(cwd).await.map(|info| ProtocolGitInfo { commit_hash: info.commit_hash, @@ -1708,6 +1746,7 @@ async fn write_session_meta( std::slice::from_ref(&rollout_item), default_provider, (!generate_memories).then_some("disabled"), + thread_updated_at_touch, ) .await; Ok(()) @@ -1720,8 +1759,10 @@ async fn sync_thread_state_after_write( items: &[RolloutItem], default_provider: &str, new_thread_memory_mode: Option<&str>, + thread_updated_at_touch: &mut ThreadUpdatedAtTouch, ) { let updated_at = Utc::now(); + let now = Instant::now(); if new_thread_memory_mode.is_some() || items .iter() @@ -1738,15 +1779,27 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); return; } let thread_id = state_builder .map(|builder| builder.id) .or_else(|| metadata::builder_from_items(items, rollout_path).map(|builder| builder.id)); + if thread_updated_at_touch + .last_persisted_at + .is_some_and(|last_persisted_at| { + now.duration_since(last_persisted_at) < THREAD_UPDATED_AT_TOUCH_INTERVAL + }) + { + thread_updated_at_touch.pending_touch = thread_id.map(|thread_id| (thread_id, updated_at)); + return; + } + if state_db::touch_thread_updated_at(state_db_ctx, thread_id, updated_at, "rollout_writer") .await { + thread_updated_at_touch.mark_persisted(now); return; } state_db::apply_rollout_items( @@ -1760,6 +1813,7 @@ async fn sync_thread_state_after_write( Some(updated_at), ) .await; + thread_updated_at_touch.mark_persisted(now); } /// Append one already-filtered rollout item to an existing rollout JSONL file. diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 35018b657d..505cd59929 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -469,7 +469,7 @@ async fn writer_state_retries_write_error_before_reporting_flush_success() -> st } #[tokio::test] -async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { +async fn metadata_irrelevant_events_coalesce_state_db_updated_at() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); @@ -519,8 +519,6 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu let initial_title = initial_thread.title.clone(); let initial_first_user_message = initial_thread.first_user_message.clone(); - tokio::time::sleep(Duration::from_secs(1)).await; - recorder .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( AgentMessageEvent { @@ -538,17 +536,123 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu .expect("thread should load after agent message") .expect("thread should still exist"); - assert!(updated_thread.updated_at > initial_updated_at); + assert_eq!(updated_thread.updated_at, initial_updated_at); assert_eq!(updated_thread.title, initial_title); assert_eq!( updated_thread.first_user_message, initial_first_user_message ); + tokio::time::sleep(THREAD_UPDATED_AT_TOUCH_INTERVAL + Duration::from_millis(10)).await; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "more assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + + let refreshed_thread = state_db + .get_thread(thread_id) + .await + .expect("thread should load after refresh") + .expect("thread should still exist"); + assert!(refreshed_thread.updated_at > initial_updated_at); + assert_eq!(refreshed_thread.title, initial_title); + assert_eq!( + refreshed_thread.first_user_message, + initial_first_user_message + ); + recorder.shutdown().await?; Ok(()) } +#[tokio::test] +async fn shutdown_flushes_pending_metadata_irrelevant_updated_at() -> std::io::Result<()> { + let home = TempDir::new().expect("temp dir"); + let config = test_config(home.path()); + + let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) + .await + .expect("state db should initialize"); + state_db + .mark_backfill_complete(/*last_watermark*/ None) + .await + .expect("backfill should be complete"); + + let thread_id = ThreadId::new(); + let recorder = RolloutRecorder::new( + &config, + RolloutRecorderParams::new( + thread_id, + /*forked_from_id*/ None, + SessionSource::Cli, + /*thread_source*/ None, + BaseInstructions::default(), + Vec::new(), + EventPersistenceMode::Limited, + ), + Some(state_db.clone()), + /*state_builder*/ None, + ) + .await?; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + message: "first-user-message".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]) + .await?; + recorder.persist().await?; + recorder.flush().await?; + let initial_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load") + .expect("thread should exist") + .updated_at; + + recorder + .record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage( + AgentMessageEvent { + message: "assistant text".to_string(), + phase: None, + memory_citation: None, + }, + ))]) + .await?; + recorder.flush().await?; + assert_eq!( + state_db + .get_thread(thread_id) + .await + .expect("thread should load before shutdown") + .expect("thread should still exist") + .updated_at, + initial_updated_at + ); + + recorder.shutdown().await?; + + let shutdown_updated_at = state_db + .get_thread(thread_id) + .await + .expect("thread should load after shutdown") + .expect("thread should still exist") + .updated_at; + assert!(shutdown_updated_at > initial_updated_at); + Ok(()) +} + #[tokio::test] async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> { @@ -574,6 +678,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> }, ))]; + let mut thread_updated_at_touch = ThreadUpdatedAtTouch::default(); sync_thread_state_after_write( Some(state_db.as_ref()), rollout_path.as_path(), @@ -581,6 +686,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> items.as_slice(), config.model_provider_id.as_str(), /*new_thread_memory_mode*/ None, + &mut thread_updated_at_touch, ) .await; From ab43db44a293f1ff7e1d3cff5b35e48689beacc8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 6 May 2026 19:32:28 +0200 Subject: [PATCH 22/30] feat: move auto vaccum (#21378) The initial vaccum is not needed anymore. We can consider all the DBs have been reclaimed by now --- codex-rs/state/src/runtime.rs | 19 +++---------------- codex-rs/state/src/runtime/logs.rs | 8 ++++---- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index 18f81348a2..2dc24788d8 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -172,28 +172,15 @@ fn base_sqlite_options(path: &Path) -> SqliteConnectOptions { } async fn open_state_sqlite(path: &Path, migrator: &Migrator) -> anyhow::Result { + // New state DBs should use incremental auto-vacuum, but retrofitting an + // existing DB requires a full VACUUM. Do not attempt that during process + // startup: it is maintenance work that can contend with foreground writers. let options = base_sqlite_options(path).auto_vacuum(SqliteAutoVacuum::Incremental); let pool = SqlitePoolOptions::new() .max_connections(5) .connect_with(options) .await?; migrator.run(&pool).await?; - let auto_vacuum = sqlx::query_scalar::<_, i64>("PRAGMA auto_vacuum") - .fetch_one(&pool) - .await?; - if auto_vacuum != SqliteAutoVacuum::Incremental as i64 { - // Existing state DBs need one non-transactional `VACUUM` before - // SQLite persists `auto_vacuum = INCREMENTAL` in the database header. - sqlx::query("PRAGMA auto_vacuum = INCREMENTAL") - .execute(&pool) - .await?; - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("VACUUM").execute(&pool).await; - } - // We do it on best effort. If the lock can't be acquired, it will be done at next run. - let _ = sqlx::query("PRAGMA incremental_vacuum") - .execute(&pool) - .await; Ok(pool) } diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 2223310d9f..6c878db624 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -300,10 +300,10 @@ WHERE id IN ( return Ok(()); }; self.delete_logs_before(cutoff.timestamp()).await?; - sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") - .execute(self.logs_pool.as_ref()) - .await?; - sqlx::query("PRAGMA incremental_vacuum") + // Startup cleanup should not wait behind or block foreground work. + // PASSIVE checkpoints copy whatever is immediately available and skip + // frames that would require waiting on active readers or writers. + sqlx::query("PRAGMA wal_checkpoint(PASSIVE)") .execute(self.logs_pool.as_ref()) .await?; Ok(()) From f2f5d6f6c7c4c72f48393ae57593ee6f6d6bf626 Mon Sep 17 00:00:00 2001 From: mifan-oai Date: Wed, 6 May 2026 13:46:15 -0400 Subject: [PATCH 23/30] [codex] Coordinate OpenAI docs sample with API key setup (#21263) ## Summary - Add the same API key setup coordination guidance to the embedded OpenAI Docs sample skill in `codex-rs/skills`. - Keep the skill description/frontmatter unchanged; the coordination lives only in the body. - Preserve direct OpenAI Docs routing for docs-only questions, citations, model/API guidance, conceptual explanations, and non-building examples. ## Why The Codex repo carries its own OpenAI Docs skill variant under `codex-rs/skills/src/assets/samples`. This keeps that embedded sample aligned with the other OpenAI Docs variants patched in the related PRs. ## Validation - `cargo test -p codex-skills` - `git diff --check` --- codex-rs/skills/src/assets/samples/openai-docs/SKILL.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md index eb12887b72..4994fd8802 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/SKILL.md @@ -8,6 +8,12 @@ description: "Use when the user asks how to build with OpenAI products or APIs a Provide authoritative, current guidance from OpenAI developer docs using the developers.openai.com MCP server. Always prioritize the developer docs MCP tools over web.run for OpenAI-related questions. This skill also owns model selection, API model migration, and prompt-upgrade guidance. Only if the MCP server is installed and returns no meaningful results should you fall back to web search. +## API Key Setup + +For requests to build, run, configure, debug, or implement an API-backed app, script, CLI, generator, or tool, use `openai-platform-api-key` first when available. After that credential gate is resolved, return here for current docs as needed. + +Use this skill directly for docs-only questions, citations, model/API guidance, conceptual explanations, and examples that do not require building or running an API-backed artifact. + ## Quick start - Use `mcp__openaiDeveloperDocs__search_openai_docs` to find the most relevant doc pages. From e97610cf3bd3f45e4ef5d1b76eb44a869fac0b50 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 14:46:18 -0300 Subject: [PATCH 24/30] fix(tui): keep Ctrl-C stashed drafts after /clear (#21351) ## Why When a user stashes a draft with Ctrl+C, then runs `/clear`, the fresh chat session loses the in-memory composer history that held the stashed draft. Pressing Up after `/clear` can then recall an older submitted prompt instead of the draft the user explicitly saved for later. ## What Changed - Record Ctrl+C-cleared composer text through the existing message history path, so it survives the fresh session created by `/clear`. - Keep `/clear` itself out of local slash-command recall so it does not sit ahead of the stashed draft. - Add regression coverage for the full flow: submit a prompt, stash a later draft with Ctrl+C, run `/clear`, then recall the stashed draft before the older prompt. ## How to Test 1. Start Codex with `just c`. 2. Submit a short prompt such as `ok` and wait for the turn to complete. 3. Type a new draft, press Ctrl+C, then run `/clear`. 4. Press Up and confirm the stashed draft is restored. 5. Press Up again and confirm the older submitted prompt is still reachable after the stashed draft. Targeted tests: - `cargo test -p codex-tui slash_clear_after_ctrl_c_keeps_stashed_draft_recallable` Manual verification: - Reproduced the issue in tmux with `RUST_LOG=trace just c -c log_dir=...`: before the fix, Up after `/clear` recalled the older submitted prompt. - Re-tested the same tmux flow after the fix: Up after `/clear` restored the Ctrl+C-stashed draft. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 16 +++++++---- codex-rs/tui/src/bottom_pane/mod.rs | 7 ++++- .../src/chatwidget/tests/slash_commands.rs | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0084e4604f..e7526203bc 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2852,11 +2852,11 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.textarea.set_text_clearing_elements(""); self.is_bash_mode = false; Some(InputResult::Command(cmd)) @@ -2884,12 +2884,12 @@ impl ChatComposer { return None; } if self.reject_slash_command_if_unavailable(cmd) { - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); self.record_pending_slash_command_history(); return Some(InputResult::None); } - self.stage_slash_command_history(); + self.stage_slash_command_history(cmd); let mut args_elements = Self::slash_command_args_elements(rest, rest_offset, &self.textarea.text_elements()); @@ -2965,7 +2965,10 @@ impl ChatComposer { /// Staging snapshots the rich composer state before the textarea is cleared. `ChatWidget` /// commits the staged entry after dispatch so command recall follows the submitted text, not /// the command outcome. - fn stage_slash_command_history(&mut self) { + fn stage_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(self.textarea.text().trim().to_string()); } @@ -2974,6 +2977,9 @@ impl ChatComposer { /// Popup filtering text can be partial, so recording the selected command avoids recalling /// `/di` after the user actually accepted `/diff`. fn stage_selected_slash_command_history(&mut self, cmd: SlashCommand) { + if cmd == SlashCommand::Clear { + return; + } self.stage_slash_command_history_text(format!("/{}", cmd.command())); } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fac5477924..6f804c44ff 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,6 +17,8 @@ use std::collections::VecDeque; use std::path::PathBuf; use crate::app::app_server_requests::ResolvedAppServerRequest; +use crate::app_command::AppCommand; +use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::pending_input_preview::PendingInputPreview; @@ -777,7 +779,10 @@ impl BottomPane { } pub(crate) fn clear_composer_for_ctrl_c(&mut self) { - self.composer.clear_for_ctrl_c(); + if let Some(text) = self.composer.clear_for_ctrl_c() { + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::add_to_history(text))); + } self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 1778579808..1bb4684c94 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1581,6 +1581,34 @@ async fn slash_clear_requests_ui_clear_when_idle() { assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); } +#[tokio::test] +async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + submit_composer_text(&mut chat, "ok"); + + let stashed_draft = "explain why history recall lost this draft"; + + chat.bottom_pane + .set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert_eq!(chat.bottom_pane.composer_text(), ""); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::CodexOp(Op::AddToHistory { text })) if text == stashed_draft + ); + + chat.bottom_pane + .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::ClearUi)); + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), stashed_draft); + + chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(chat.bottom_pane.composer_text(), "ok"); +} + #[tokio::test] async fn slash_clear_is_disabled_while_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 123ec8b03538c4bb32f5e26daec2c23030870c52 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 6 May 2026 11:10:30 -0700 Subject: [PATCH 25/30] vendor: update bubblewrap to 0.11.2 (#21389) ## Why `codex-rs/vendor/bubblewrap` had fallen behind upstream, and upstream `v0.11.2` is the current Bubblewrap release. The release is a security update for `CVE-2026-41163`, affecting setuid Bubblewrap builds, and deprecates setuid support in favor of the default non-setuid build mode. ## What changed - Refreshed the vendored Bubblewrap sources under `codex-rs/vendor/bubblewrap` to upstream `v0.11.2`. - Brought in the upstream `-Dsupport_setuid` build option, which defaults setuid support off. - Updated vendored release notes and documentation files included with Bubblewrap. ## Verification Not run locally; this PR only refreshes the vendored upstream Bubblewrap source snapshot. Upstream release: https://github.com/containers/bubblewrap/releases/tag/v0.11.2 --- codex-rs/vendor/bubblewrap/NEWS.md | 47 +++++++++++++++++ codex-rs/vendor/bubblewrap/README.md | 31 ++++++------ codex-rs/vendor/bubblewrap/SECURITY.md | 7 +++ codex-rs/vendor/bubblewrap/bubblewrap.c | 50 +++++++++++++------ codex-rs/vendor/bubblewrap/meson.build | 7 ++- codex-rs/vendor/bubblewrap/meson_options.txt | 6 +++ codex-rs/vendor/bubblewrap/network.c | 4 +- .../vendor/bubblewrap/release-checklist.md | 6 +-- codex-rs/vendor/bubblewrap/utils.c | 13 +++-- 9 files changed, 131 insertions(+), 40 deletions(-) diff --git a/codex-rs/vendor/bubblewrap/NEWS.md b/codex-rs/vendor/bubblewrap/NEWS.md index da232c4bd7..eb82ed98d9 100644 --- a/codex-rs/vendor/bubblewrap/NEWS.md +++ b/codex-rs/vendor/bubblewrap/NEWS.md @@ -1,3 +1,50 @@ +bubblewrap 0.11.2 +================= + +Released: 2026-04-23 + +Bug fixes: + + * In setuid mode, don't run the low-privileged parts parts of the setup + as dumpable, as that allows it to be ptraced which can lead to problems. + This is CVE-2026-41163, and was reported by François Diakhate. + +Enhancements: + + * New build option `-Dsupport_setuid`, which if set to false (which + is the default) disables the support for setuid. Binaries built + with this will refuse to run if made setuid. We recommend building + normal bubblewrap binaries like this, which allows you to safely + ignore any security issues that only affect setuid mode. + +bubblewrap 0.11.1 +================= + +Released: 2026-03-21 + +Bug fixes: + + * Reset disposition of `SIGCHLD`, restoring normal subprocess management + if bwrap was run from a process that was ignoring that signal, + such as Erlang or volumeicon (#705, Joel Pelaez Jorge) + + * Don't ignore `--userns 0`, `--userns2 0` or `--pidns 0` if used + (#731, Daniel Cazares). + Note that using a fd number ≥ 3 for these purposes is still + preferred, to avoid confusion with the stdin, stdout, stderr + that will be inherited by the command inside the container. + + * Fix grammar in an error message (#694, J. Neuschäfer) + + * Fix a broken link in the documentation (#729, Aaron Brooks) + +Internal changes: + + * Enable user namespaces in Github Actions configuration, fixing a CI + regression with newer Ubuntu (#728, Joel Pelaez Jorge) + + * Clarify comments (#737, Simon McVittie) + bubblewrap 0.11.0 ================= diff --git a/codex-rs/vendor/bubblewrap/README.md b/codex-rs/vendor/bubblewrap/README.md index c16cd7d89a..1f838ce0a1 100644 --- a/codex-rs/vendor/bubblewrap/README.md +++ b/codex-rs/vendor/bubblewrap/README.md @@ -12,23 +12,24 @@ on the host. User namespaces --------------- -There is an effort in the Linux kernel called +There is an feature in the Linux kernel called [user namespaces](https://www.google.com/search?q=user+namespaces+site%3Ahttps%3A%2F%2Flwn.net) -which attempts to allow unprivileged users to use container features. -While significant progress has been made, there are -[still concerns](https://lwn.net/Articles/673597/) about it, and -it is not available to unprivileged users in several production distributions -such as CentOS/Red Hat Enterprise Linux 7, Debian Jessie, etc. +which allows unprivileged users to use container features. Bubblewrap uses these to +build the sandbox, allowing any user to use the tool. -See for example -[CVE-2016-3135](https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-3135) -which is a local root vulnerability introduced by userns. -[This March 2016 post](https://lkml.org/lkml/2016/3/9/555) has some -more discussion. +Historically, not all Linux distributions supported (at least by +default) unprivileged user namespaces, so bubblewrap supports a second +mode of operation when the binary is setuid root. In that setup +bubblewrap could be viewed as setuid implementation of a *subset* of +user namespaces. However, not all features of bubblewrap work in +this mode. -Bubblewrap could be viewed as setuid implementation of a *subset* of -user namespaces. Emphasis on subset - specifically relevant to the -above CVE, bubblewrap does not allow control over iptables. +However, setuid mode is deprecated, as most recent Linux distributions +support unprivileged user namespaces, and setuid binaries carry +significant risks. By default, bubblewrap binaries refuse to work if +setuid, and you must build explicitly with ` -Dsupport_setuid=true` to +enable it to work. Later versions of bubblewrap aims to completely +remove this support. The original bubblewrap code existed before user namespaces - it inherits code from [xdg-app helper](https://cgit.freedesktop.org/xdg-app/xdg-app/tree/common/xdg-app-helper.c?id=4c3bf179e2e4a2a298cd1db1d045adaf3f564532) @@ -151,7 +152,7 @@ sandbox. You can also change what the value of uid/gid should be in the sandbox. IPC namespaces ([CLONE_NEWIPC](https://linux.die.net/man/2/clone)): The sandbox will get its own copy of all the different forms of IPCs, like SysV shared memory and semaphores. -PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/2015/01/20/docker-and-the-pid-1-zombie-reaping-problem/). +PID namespaces ([CLONE_NEWPID](https://linux.die.net/man/2/clone)): The sandbox will not see any processes outside the sandbox. Additionally, bubblewrap will run a trivial pid1 inside your container to handle the requirements of reaping children in the sandbox. This avoids what is known now as the [Docker pid 1 problem](https://blog.phusion.nl/docker-and-the-pid-1-zombie-reaping-problem/). Network namespaces ([CLONE_NEWNET](https://linux.die.net/man/2/clone)): The sandbox will not see the network. Instead it will have its own network namespace with only a loopback device. diff --git a/codex-rs/vendor/bubblewrap/SECURITY.md b/codex-rs/vendor/bubblewrap/SECURITY.md index 0ddfc6c873..7e0fb32746 100644 --- a/codex-rs/vendor/bubblewrap/SECURITY.md +++ b/codex-rs/vendor/bubblewrap/SECURITY.md @@ -15,6 +15,13 @@ between the user and the OS, because anything bubblewrap could do, a malicious user could equally well do by writing their own tool equivalent to bubblewrap. +Since 0.11.2, unless compiled with the `-Dsupport_setuid=true` option, +setuid root support is disabled. In this mode bubblewrap will refuse +to operate if the binary has been made setuid. For binaries built like +this it is safe to ignore any bubblewrap CVEs that are described as +affecting setuid mode only. This is the recommended way to package +bubblewrap. + ### Sandbox security bubblewrap is a toolkit for constructing sandbox environments. diff --git a/codex-rs/vendor/bubblewrap/bubblewrap.c b/codex-rs/vendor/bubblewrap/bubblewrap.c index 69d319b7a3..9039ddfa80 100644 --- a/codex-rs/vendor/bubblewrap/bubblewrap.c +++ b/codex-rs/vendor/bubblewrap/bubblewrap.c @@ -55,7 +55,11 @@ static uid_t real_uid; static gid_t real_gid; static uid_t overflow_uid; static gid_t overflow_gid; +#ifdef ENABLE_SUPPORT_SETUID static bool is_privileged; /* See acquire_privs() */ +#else +#define is_privileged 0 +#endif static const char *argv0; static const char *host_tty_dev; static int proc_fd = -1; @@ -840,13 +844,16 @@ set_ambient_capabilities (void) static void acquire_privs (void) { - uid_t euid, new_fsuid; + uid_t euid; euid = geteuid (); /* Are we setuid ? */ if (real_uid != euid) { +#ifdef ENABLE_SUPPORT_SETUID + uid_t new_fsuid; + if (euid != 0) die ("Unexpected setuid user %d, should be 0", euid); @@ -868,13 +875,16 @@ acquire_privs (void) /* setfsuid can't properly report errors, check that it worked (as per manpage) */ new_fsuid = setfsuid (-1); if (new_fsuid != real_uid) - die ("Unable to set fsuid (was %d)", (int)new_fsuid); + die_with_error ("Unable to set fsuid (was %d)", (int)new_fsuid); /* We never need capabilities after execve(), so lets drop everything from the bounding set */ drop_cap_bounding_set (true); /* Keep only the required capabilities for setup */ set_required_caps (); +#else + die ("setuid use of bubblewrap is not supported in this build"); +#endif } else if (real_uid != 0 && has_caps ()) { @@ -937,7 +947,8 @@ switch_to_user_with_privs (void) /* Call setuid() and use capset() to adjust capabilities */ static void drop_privs (bool keep_requested_caps, - bool already_changed_uid) + bool already_changed_uid, + bool set_dumpable) { assert (!keep_requested_caps || !is_privileged); /* Drop root uid */ @@ -947,9 +958,12 @@ drop_privs (bool keep_requested_caps, drop_all_caps (keep_requested_caps); - /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ - if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) - die_with_error ("can't set dumpable"); + if (set_dumpable) + { + /* We don't have any privs now, so mark us dumpable which makes /proc/self be owned by the user instead of root */ + if (prctl (PR_SET_DUMPABLE, 1, 0, 0, 0) != 0) + die_with_error ("can't set dumpable"); + } } static void @@ -1154,7 +1168,9 @@ privileged_op (int privileged_op_socket, break; case PRIV_SEP_OP_OVERLAY_MOUNT: - if (mount ("overlay", arg2, "overlay", MS_MGC_VAL, arg1) != 0) + if (is_privileged) + die ("Overlay mounts are not supported in setuid mode"); + if (mount ("overlay", arg2, "overlay", MS_MGC_VAL | MS_NOSUID | MS_NODEV, arg1) != 0) { /* The standard message for ELOOP, "Too many levels of symbolic * links", is not helpful here. */ @@ -1172,6 +1188,8 @@ privileged_op (int privileged_op_socket, something manages to send hacked priv-sep operation requests. */ if (!opt_unshare_uts) die ("Refusing to set hostname in original namespace"); + if (arg1 == NULL) + die ("Hostname argument is NULL"); if (sethostname (arg1, strlen(arg1)) != 0) die_with_error ("Can't set hostname to %s", arg1); break; @@ -3112,7 +3130,7 @@ main (int argc, } /* Switch to the custom user ns before the clone, gets us privs in that ns (assuming its a child of the current and thus allowed) */ - if (opt_userns_fd > 0 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) + if (opt_userns_fd != -1 && setns (opt_userns_fd, CLONE_NEWUSER) != 0) { if (errno == EINVAL) die ("Joining the specified user namespace failed, it might not be a descendant of the current user namespace."); @@ -3178,11 +3196,11 @@ main (int argc, /* Initial launched process, wait for pid 1 or exec:ed command to exit */ - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); /* We don't need any privileges in the launcher, drop them immediately. */ - drop_privs (false, false); + drop_privs (false, false, true); /* Optionally bind our lifecycle to that of the parent */ handle_die_with_parent (); @@ -3219,7 +3237,7 @@ main (int argc, return monitor_child (event_fd, pid, setup_finished_pipe[0]); } - if (opt_pidns_fd > 0) + if (opt_pidns_fd != -1) { if (setns (opt_pidns_fd, CLONE_NEWPID) != 0) die_with_error ("Setting pidns failed"); @@ -3369,8 +3387,10 @@ main (int argc, if (child == 0) { - /* Unprivileged setup process */ - drop_privs (false, true); + /* Unprivileged setup process. + * Note: Don't set dumpable, because we can still perform privileged + * operations via privileged_op(). */ + drop_privs (false, true, false); close (privsep_sockets[0]); setup_newroot (opt_unshare_pid, privsep_sockets[1]); exit (0); @@ -3446,7 +3466,7 @@ main (int argc, die_with_error ("chdir /"); } - if (opt_userns2_fd > 0 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) + if (opt_userns2_fd != -1 && setns (opt_userns2_fd, CLONE_NEWUSER) != 0) die_with_error ("Setting userns2 failed"); if (opt_unshare_user && opt_userns_block_fd == -1 && @@ -3499,7 +3519,7 @@ main (int argc, } /* All privileged ops are done now, so drop caps we don't need */ - drop_privs (!is_privileged, true); + drop_privs (!is_privileged, true, true); if (opt_block_fd != -1) { diff --git a/codex-rs/vendor/bubblewrap/meson.build b/codex-rs/vendor/bubblewrap/meson.build index 78678d0973..520d0a5f47 100644 --- a/codex-rs/vendor/bubblewrap/meson.build +++ b/codex-rs/vendor/bubblewrap/meson.build @@ -1,7 +1,7 @@ project( 'bubblewrap', 'c', - version : '0.11.0', + version : '0.11.2', meson_version : '>=0.49.0', default_options : [ 'warning_level=2', @@ -91,6 +91,11 @@ if get_option('require_userns') cdata.set('ENABLE_REQUIRE_USERNS', 1) endif +if get_option('support_setuid') + cdata.set('ENABLE_SUPPORT_SETUID', 1) + warning('running bubblewrap setuid is deprecated and risky. Most recent operating systems support unprivileged user namespaces and we recommend using that. Support for this will be removed in the next version.') +endif + configure_file( output : 'config.h', configuration : cdata, diff --git a/codex-rs/vendor/bubblewrap/meson_options.txt b/codex-rs/vendor/bubblewrap/meson_options.txt index 5e25ee86f8..05b1c7873e 100644 --- a/codex-rs/vendor/bubblewrap/meson_options.txt +++ b/codex-rs/vendor/bubblewrap/meson_options.txt @@ -41,6 +41,12 @@ option( type : 'string', description : 'Path to Python 3, or empty to use python3', ) +option( + 'support_setuid', + type : 'boolean', + description : 'Support setuid mode (deprecated)', + value : false, +) option( 'require_userns', type : 'boolean', diff --git a/codex-rs/vendor/bubblewrap/network.c b/codex-rs/vendor/bubblewrap/network.c index 106e6d6e36..373d606a0a 100644 --- a/codex-rs/vendor/bubblewrap/network.c +++ b/codex-rs/vendor/bubblewrap/network.c @@ -50,7 +50,7 @@ static int rtnl_send_request (int rtnl_fd, struct nlmsghdr *header) { - struct sockaddr_nl dst_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl dst_addr = { AF_NETLINK, 0 }; ssize_t sent; sent = TEMP_FAILURE_RETRY (sendto (rtnl_fd, (void *) header, header->nlmsg_len, 0, @@ -139,7 +139,7 @@ loopback_setup (void) int r, if_loopback; cleanup_fd int rtnl_fd = -1; char buffer[1024]; - struct sockaddr_nl src_addr = { .nl_family = AF_NETLINK, .nl_pid = 0, .nl_groups = 0 }; + struct sockaddr_nl src_addr = { AF_NETLINK, 0 }; struct nlmsghdr *header; struct ifaddrmsg *addmsg; struct ifinfomsg *infomsg; diff --git a/codex-rs/vendor/bubblewrap/release-checklist.md b/codex-rs/vendor/bubblewrap/release-checklist.md index 5b2119c8af..0c1479e447 100644 --- a/codex-rs/vendor/bubblewrap/release-checklist.md +++ b/codex-rs/vendor/bubblewrap/release-checklist.md @@ -1,13 +1,13 @@ bubblewrap release checklist ============================ -* Collect release notes in `NEWS` -* Update version number in `meson.build` and release date in `NEWS` +* Collect release notes in `NEWS.md` +* Update version number in `meson.build` and release date in `NEWS.md` * Commit the changes * `meson dist -C ${builddir}` * Do any final smoke-testing, e.g. update a package, install and test it * `git evtag sign v$VERSION` - * Include the release notes from `NEWS` in the tag message + * Include the release notes from `NEWS.md` in the tag message * `git push --atomic origin main v$VERSION` * https://github.com/containers/bubblewrap/releases/new * Fill in the new version's tag in the "Tag version" box diff --git a/codex-rs/vendor/bubblewrap/utils.c b/codex-rs/vendor/bubblewrap/utils.c index 51875aea9a..7b7349ab82 100644 --- a/codex-rs/vendor/bubblewrap/utils.c +++ b/codex-rs/vendor/bubblewrap/utils.c @@ -510,14 +510,18 @@ ensure_file (const char *path, the create file will fail in the read-only case with EROFS instead of EEXIST. - We're trying to set up a mount point for a non-directory, so any - non-directory, non-symlink is acceptable - it doesn't necessarily - have to be a regular file. */ + We're trying to set up a mount point for a non-directory, for which + the kernel will accept any non-directory. If it's a symlink, follow + it and look at the target: again, any non-directory is good enough. + We'll only get S_ISLNK if the path is a dangling symlink (target + doesn't exist). */ if (stat (path, &buf) == 0 && !S_ISDIR (buf.st_mode) && !S_ISLNK (buf.st_mode)) return 0; + /* If the file didn't exist, create it. If it was a dangling symlink + * (S_ISLNK above) then this will create the target of the symlink. */ if (create_file (path, mode, NULL) != 0 && errno != EEXIST) return -1; @@ -681,7 +685,8 @@ ensure_dir (const char *path, /* We check this ahead of time, otherwise the mkdir call can fail in the read-only case with EROFS instead of EEXIST on some - filesystems (such as NFS) */ + filesystems (such as NFS). + We follow symlinks: it's OK if path is a symlink to a directory. */ if (stat (path, &buf) == 0) { if (!S_ISDIR (buf.st_mode)) From 712305be4787e69a6af775f633c35c6cbb135421 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 6 May 2026 11:20:34 -0700 Subject: [PATCH 26/30] Remove core MCP list tools op (#21281) ## Why The core `Op::ListMcpTools` request path is no longer needed. Keeping it around left a dead request/response surface alongside the app-server MCP inventory APIs that own current server status listing. ## What Changed - Removed `Op::ListMcpTools`, `EventMsg::McpListToolsResponse`, and the core handler that built the MCP snapshot response. - Removed the now-unused `codex-mcp` snapshot wrapper/export and passive event handling arms in rollout and MCP-server consumers. - Updated tests that used the old op as a synchronization hook to wait on existing startup/skills events, and deleted the plugin test that only exercised the removed listing op. ## Validation - `cargo test -p codex-protocol` - `cargo test -p codex-mcp` - `cargo test -p codex-rollout -p codex-rollout-trace -p codex-mcp-server` - `cargo test -p codex-core --test all pending_input::queued_inter_agent_mail` - `cargo test -p codex-core --test all rmcp_client::stdio_mcp_tool_call_includes_sandbox_state_meta` - `cargo test -p codex-core --test all rmcp_client::stdio_image_responses` - `just fix -p codex-core -p codex-protocol -p codex-mcp -p codex-rollout -p codex-rollout-trace -p codex-mcp-server` --- codex-rs/Cargo.lock | 1 + codex-rs/codex-mcp/src/lib.rs | 1 - codex-rs/codex-mcp/src/mcp/mod.rs | 51 ---------- codex-rs/core/src/session/handlers.rs | 35 ------- codex-rs/core/src/session/turn.rs | 1 - codex-rs/core/tests/common/Cargo.toml | 1 + codex-rs/core/tests/suite/pending_input.rs | 6 +- codex-rs/core/tests/suite/plugins.rs | 45 --------- codex-rs/core/tests/suite/rmcp_client.rs | 98 ++++++++------------ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/protocol.rs | 23 ----- codex-rs/rollout-trace/src/protocol_event.rs | 2 - codex-rs/rollout/src/policy.rs | 1 - 13 files changed, 46 insertions(+), 220 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8419848ecd..f78f27ffa3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4275,6 +4275,7 @@ dependencies = [ "reqwest", "serde_json", "shlex", + "similar", "tempfile", "tokio", "tokio-tungstenite", diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 54631cd1c1..2080a5c319 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -32,7 +32,6 @@ pub use mcp::with_codex_apps_mcp; pub use mcp::McpServerStatusSnapshot; pub use mcp::McpSnapshotDetail; pub use mcp::collect_mcp_server_status_snapshot_with_detail; -pub use mcp::collect_mcp_snapshot_from_manager; pub use mcp::read_mcp_resource; pub use mcp::McpAuthStatusEntry; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 958677b9e8..03a5d1a068 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -31,7 +31,6 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::McpListToolsResponseEvent; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -347,18 +346,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( snapshot } -pub async fn collect_mcp_snapshot_from_manager( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager, - auth_status_entries, - McpSnapshotDetail::Full, - ) - .await -} - pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { codex_apps_mcp_url_for_base_url( &config.chatgpt_base_url, @@ -595,44 +582,6 @@ async fn collect_mcp_server_status_snapshot_from_manager( } } -async fn collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, - detail: McpSnapshotDetail, -) -> McpListToolsResponseEvent { - let (tools, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resources().await - } else { - HashMap::new() - } - }, - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await - } else { - HashMap::new() - } - }, - ); - - let tools = tools - .into_iter() - .filter_map(|(name, tool)| { - protocol_tool_from_rmcp_tool(&name, &tool.tool).map(|tool| (name, tool)) - }) - .collect::>(); - - McpListToolsResponseEvent { - tools, - resources: convert_mcp_resources(resources), - resource_templates: convert_mcp_resource_templates(resource_templates), - auth_statuses: auth_statuses_from_entries(&auth_status_entries), - } -} - #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index b57798e5c2..1dc68a69f3 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -24,8 +24,6 @@ use crate::tasks::CompactTask; use crate::tasks::UserShellCommandMode; use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; -use codex_mcp::collect_mcp_snapshot_from_manager; -use codex_mcp::compute_auth_statuses; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::CodexErrorInfo; @@ -469,35 +467,6 @@ pub async fn reload_user_config(sess: &Arc) { sess.reload_user_config_layer().await; } -#[expect( - clippy::await_holding_invalid_type, - reason = "MCP tool listing reads through the session-owned manager guard" -)] -pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { - let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let auth = sess.services.auth_manager.auth().await; - let mcp_servers = sess - .services - .mcp_manager - .effective_servers(config, auth.as_ref()) - .await; - let snapshot = collect_mcp_snapshot_from_manager( - &mcp_connection_manager, - compute_auth_statuses( - mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - auth.as_ref(), - ) - .await, - ) - .await; - let event = Event { - id: sub_id, - msg: EventMsg::McpListToolsResponse(snapshot), - }; - sess.send_event_raw(event).await; -} - pub async fn compact(sess: &Arc, sub_id: String) { let turn_context = sess.new_default_turn_with_sub_id(sub_id).await; @@ -863,10 +832,6 @@ pub(super) async fn submission_loop( dynamic_tool_response(&sess, id, response).await; false } - Op::ListMcpTools => { - list_mcp_tools(&sess, &config, sub.id.clone()).await; - false - } Op::RefreshMcpServers { config } => { refresh_mcp_servers(&sess, config).await; false diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index fb2d9caeb7..7aa64aae57 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1509,7 +1509,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::DeprecationNotice(_) | EventMsg::StreamError(_) | EventMsg::TurnDiff(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index c59edf57a6..8c1a79f02d 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -33,6 +33,7 @@ opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } +similar = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["net", "time"] } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index b582a2abc7..62851c515d 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -164,11 +164,11 @@ async fn submit_queue_only_agent_mail(codex: &CodexThread, text: &str) { .await .unwrap_or_else(|err| panic!("submit queue-only agent mail: {err}")); codex - .submit(Op::ListMcpTools) + .submit(Op::RealtimeConversationListVoices) .await - .unwrap_or_else(|err| panic!("submit list-mcp-tools barrier: {err}")); + .unwrap_or_else(|err| panic!("submit list-voices barrier: {err}")); wait_for_event(codex, |event| { - matches!(event, EventMsg::McpListToolsResponse(_)) + matches!(event, EventMsg::RealtimeConversationListVoicesResponse(_)) }) .await; } diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index df042029ae..7b10db0259 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -99,20 +99,6 @@ fn write_plugin_app_plugin(home: &TempDir) { .expect("write plugin app config"); } -async fn build_plugin_test_codex( - server: &MockServer, - codex_home: Arc, -) -> Result> { - let mut builder = test_codex() - .with_home(codex_home) - .with_auth(CodexAuth::from_api_key("Test API Key")); - Ok(builder - .build(server) - .await - .expect("create new conversation") - .codex) -} - async fn build_analytics_plugin_test_codex( server: &MockServer, codex_home: Arc, @@ -448,34 +434,3 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { Ok(()) } - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn plugin_mcp_tools_are_listed() -> Result<()> { - skip_if_no_network!(Ok(())); - let server = start_mock_server().await; - let codex_home = Arc::new(TempDir::new()?); - let rmcp_test_server_bin = stdio_server_bin()?; - write_plugin_mcp_plugin(codex_home.as_ref(), &rmcp_test_server_bin); - let codex = build_plugin_test_codex(&server, codex_home).await?; - wait_for_sample_mcp_ready(&codex).await?; - - codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - let mut available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - available_tools.sort_unstable(); - assert!( - tool_list.tools.contains_key("mcp__sample__echo") - && tool_list.tools.contains_key("mcp__sample__image"), - "expected plugin MCP tools to be listed; discovered tools: {available_tools:?}" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 8b7b4211cf..1a7062bdb1 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -242,31 +242,42 @@ fn copy_binary_to_remote_env( Ok(remote_path) } -async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> { - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(tool_name) { - return Ok(()); - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; +async fn wait_for_mcp_server(fixture: &TestCodex, server_name: &str) -> anyhow::Result<()> { + let startup_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| match ev { + EventMsg::McpStartupComplete(summary) => { + summary.ready.iter().any(|server| server == server_name) + || summary + .failed + .iter() + .any(|failure| failure.server == server_name) + || summary.cancelled.iter().any(|server| server == server_name) + } + _ => false, + }, + Duration::from_secs(70), + ) + .await; + let EventMsg::McpStartupComplete(summary) = startup_event else { + unreachable!("event guard guarantees McpStartupComplete"); + }; + if let Some(failure) = summary + .failed + .iter() + .find(|failure| failure.server == server_name) + { + let error = &failure.error; + anyhow::bail!("MCP server {server_name} failed to start: {error}"); } + if summary.cancelled.iter().any(|server| server == server_name) { + anyhow::bail!("MCP server {server_name} startup was cancelled"); + } + ensure!( + summary.ready.iter().any(|server| server == server_name), + "expected MCP server {server_name} to be ready; startup summary: {summary:?}" + ); + Ok(()) } #[derive(Default)] @@ -731,7 +742,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let call_id = "sandbox-meta-call"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}__"); - let tool_name = format!("{namespace}sandbox_meta"); let call_mock = mount_sse_once( &server, @@ -767,30 +777,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> .build_remote_aware(&server) .await?; - let tools_ready_deadline = Instant::now() + Duration::from_secs(30); - loop { - fixture.codex.submit(Op::ListMcpTools).await?; - let list_event = wait_for_event_with_timeout( - &fixture.codex, - |ev| matches!(ev, EventMsg::McpListToolsResponse(_)), - Duration::from_secs(10), - ) - .await; - let EventMsg::McpListToolsResponse(tool_list) = list_event else { - unreachable!("event guard guarantees McpListToolsResponse"); - }; - if tool_list.tools.contains_key(&tool_name) { - break; - } - - let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect(); - if Instant::now() >= tools_ready_deadline { - panic!( - "timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}" - ); - } - sleep(Duration::from_millis(200)).await; - } + wait_for_mcp_server(&fixture, server_name).await?; fixture .submit_turn_with_permission_profile( @@ -1039,7 +1026,6 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { let call_id = "img-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image"); let namespace = format!("mcp__{server_name}__"); // First stream: model decides to call the image tool. @@ -1086,7 +1072,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1176,7 +1162,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re let call_id = "img-original-detail-1"; let server_name = "rmcp"; - let tool_name = format!("mcp__{server_name}__image_scenario"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -1219,7 +1204,7 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re }) .build_remote_aware(&server) .await?; - wait_for_mcp_tool(&fixture, &tool_name).await?; + wait_for_mcp_server(&fixture, server_name).await?; fixture .codex @@ -1955,7 +1940,6 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { let call_id = "call-789"; let server_name = "rmcp_http_oauth"; - let tool_name = format!("mcp__{server_name}__echo"); let namespace = format!("mcp__{server_name}__"); mount_sse_once( @@ -2036,9 +2020,9 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { }) .build_remote_aware(&server) .await?; - // Phase 5: wait for MCP discovery to publish the expected tool before the - // turn is submitted, which keeps failures tied to server startup/discovery. - wait_for_mcp_tool(&fixture, &tool_name).await?; + // Phase 5: wait for MCP startup before the turn is submitted, which keeps + // failures tied to server startup/discovery. + wait_for_mcp_server(&fixture, server_name).await?; // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 8917ebf823..b462022dbc 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -334,7 +334,6 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index efa4554ebd..4eb1b8804f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -30,9 +30,6 @@ use crate::dynamic_tools::DynamicToolSpec; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; -use crate::mcp::Resource as McpResource; -use crate::mcp::ResourceTemplate as McpResourceTemplate; -use crate::mcp::Tool as McpTool; use crate::memory_citation::MemoryCitation; use crate::models::ActivePermissionProfile; use crate::models::BaseInstructions; @@ -722,10 +719,6 @@ pub enum Op { response: DynamicToolResponse, }, - /// Request the list of MCP tools available across all configured servers. - /// Reply is delivered via `EventMsg::McpListToolsResponse`. - ListMcpTools, - /// Request MCP servers to reinitialize and refresh cached tool lists. RefreshMcpServers { config: McpServerRefreshConfig }, @@ -862,7 +855,6 @@ impl Op { Self::UserInputAnswer { .. } => "user_input_answer", Self::RequestPermissionsResponse { .. } => "request_permissions_response", Self::DynamicToolResponse { .. } => "dynamic_tool_response", - Self::ListMcpTools => "list_mcp_tools", Self::RefreshMcpServers { .. } => "refresh_mcp_servers", Self::ReloadUserConfig => "reload_user_config", Self::Compact => "compact", @@ -1406,9 +1398,6 @@ pub enum EventMsg { TurnDiff(TurnDiffEvent), - /// List of MCP tools available to the agent. - McpListToolsResponse(McpListToolsResponseEvent), - /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), @@ -3247,18 +3236,6 @@ pub struct TurnDiffEvent { pub unified_diff: String, } -#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] -pub struct McpListToolsResponseEvent { - /// Fully qualified tool name -> tool definition. - pub tools: std::collections::HashMap, - /// Known resources grouped by server name. - pub resources: std::collections::HashMap>, - /// Known resource templates grouped by server name. - pub resource_templates: std::collections::HashMap>, - /// Authentication status for each configured MCP server. - pub auth_statuses: std::collections::HashMap, -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct McpStartupUpdateEvent { /// Server name being started. diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 91ee3f09a3..3d52798b8d 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -259,7 +259,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::PatchApplyUpdated(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 39c865a839..558c3fef98 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -154,7 +154,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyUpdated(_) | EventMsg::TurnDiff(_) - | EventMsg::McpListToolsResponse(_) | EventMsg::RealtimeConversationListVoicesResponse(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) From f32c496144f3ae4617e401f8dbbbe337a363057c Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 6 May 2026 11:53:26 -0700 Subject: [PATCH 27/30] [codex] Handle git pagination flags by position (#21381) ## Why This is a follow-up to the Windows Git safe-command bypass fix for BUGB-15601. Git's global `--paginate` / `-p` flags can route output through a configured pager, so they should not be auto-approved as safe before the subcommand. At the same time, `-p` after read-only subcommands like `log`, `diff`, and `show` is the common patch-output flag, so treating every `-p` as unsafe would make ordinary read-only inspection commands prompt unnecessarily. ## What Changed - Split Git option safety matching into explicit global-option and subcommand-option lists. - Treat global `git --paginate ...` and `git -p ...` as unsafe. - Keep post-subcommand patch usage such as `git log -p`, `git diff -p`, and `git show -p HEAD` safe. - Keep the pagination coverage with the shared Git safe-command implementation rather than the Windows wrapper tests. - Remove the stale `git_global_option_requires_prompt` helper now that safe-command Git option matching owns the prompt-required lists. ## Testing - `cargo test -p codex-shell-command` --- .../command_safety/is_dangerous_command.rs | 32 ----- .../src/command_safety/is_safe_command.rs | 129 ++++++++++++++---- .../command_safety/windows_safe_commands.rs | 2 - 3 files changed, 102 insertions(+), 61 deletions(-) diff --git a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs index 19babd82e8..fef98e8362 100644 --- a/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_dangerous_command.rs @@ -68,32 +68,6 @@ fn is_git_global_option_with_inline_value(arg: &str) -> bool { ) || ((arg.starts_with("-C") || arg.starts_with("-c")) && arg.len() > 2) } -/// Git global options that can redirect config, repository, or helper lookup -/// and therefore must never be auto-approved as "safe". -pub(crate) fn git_global_option_requires_prompt(arg: &str) -> bool { - matches!( - arg, - // `-C` can redirect Git into a repo whose config runs helpers such as - // `core.fsmonitor` during read-only commands like `status`. - "-C" | "-c" - | "--config-env" - | "--exec-path" - | "--git-dir" - | "--namespace" - | "--super-prefix" - | "--work-tree" - ) || matches!( - arg, - s if ((s.starts_with("-C") || s.starts_with("-c")) && s.len() > 2) - || s.starts_with("--config-env=") - || s.starts_with("--exec-path=") - || s.starts_with("--git-dir=") - || s.starts_with("--namespace=") - || s.starts_with("--super-prefix=") - || s.starts_with("--work-tree=") - ) -} - pub(crate) fn executable_name_lookup_key(raw: &str) -> Option { #[cfg(windows)] { @@ -200,12 +174,6 @@ mod tests { assert!(command_might_be_dangerous(&vec_str(&["rm", "-f", "/"]))); } - #[test] - fn git_dash_c_requires_prompt() { - assert!(git_global_option_requires_prompt("-C")); - assert!(git_global_option_requires_prompt("-C/path/to/repo")); - } - #[test] fn direct_powershell_words_reuse_windows_dangerous_detection() { let command = vec_str(&["Remove-Item", "test", "-Force"]); diff --git a/codex-rs/shell-command/src/command_safety/is_safe_command.rs b/codex-rs/shell-command/src/command_safety/is_safe_command.rs index 7200672ec1..b35144a12b 100644 --- a/codex-rs/shell-command/src/command_safety/is_safe_command.rs +++ b/codex-rs/shell-command/src/command_safety/is_safe_command.rs @@ -4,7 +4,6 @@ use crate::command_safety::is_dangerous_command::executable_name_lookup_key; // may appear before it (e.g., `-C`, `-c`, `--git-dir`). // Implemented in `is_dangerous_command` and shared here. use crate::command_safety::is_dangerous_command::find_git_subcommand; -use crate::command_safety::is_dangerous_command::git_global_option_requires_prompt; use crate::command_safety::windows_safe_commands::is_safe_command_windows; #[cfg(windows)] use crate::command_safety::windows_safe_commands::is_safe_powershell_words as is_safe_powershell_words_windows; @@ -170,19 +169,17 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } pub(crate) fn is_safe_git_command(command: &[String]) -> bool { - // Global options that redirect config, repository, or helper lookup can make - // otherwise read-only git commands execute attacker-controlled code, so they - // must never be auto-approved. - if git_has_unsafe_global_option(command) { - return false; - } - let Some((subcommand_idx, subcommand)) = find_git_subcommand(command, &["status", "log", "diff", "show", "branch"]) else { return false; }; + let global_args = &command[1..subcommand_idx]; + if git_has_unsafe_global_option(global_args) { + return false; + } + let subcommand_args = &command[subcommand_idx + 1..]; match subcommand { @@ -226,30 +223,71 @@ fn git_branch_is_read_only(branch_args: &[String]) -> bool { saw_read_only_flag } -fn git_has_unsafe_global_option(command: &[String]) -> bool { - command +#[derive(Clone, Copy)] +enum GitOptionPattern { + Exact(&'static str), + ShortWithInlineValue(&'static str), + Prefix(&'static str), +} + +const UNSAFE_GIT_GLOBAL_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("-C"), + GitOptionPattern::ShortWithInlineValue("-C"), + GitOptionPattern::Exact("-c"), + GitOptionPattern::ShortWithInlineValue("-c"), + GitOptionPattern::Exact("-p"), + GitOptionPattern::Exact("--config-env"), + GitOptionPattern::Prefix("--config-env="), + GitOptionPattern::Exact("--exec-path"), + GitOptionPattern::Prefix("--exec-path="), + GitOptionPattern::Exact("--git-dir"), + GitOptionPattern::Prefix("--git-dir="), + GitOptionPattern::Exact("--namespace"), + GitOptionPattern::Prefix("--namespace="), + GitOptionPattern::Exact("--paginate"), + GitOptionPattern::Exact("--super-prefix"), + GitOptionPattern::Prefix("--super-prefix="), + GitOptionPattern::Exact("--work-tree"), + GitOptionPattern::Prefix("--work-tree="), +]; + +const UNSAFE_GIT_SUBCOMMAND_OPTIONS: &[GitOptionPattern] = &[ + GitOptionPattern::Exact("--output"), + GitOptionPattern::Prefix("--output="), + GitOptionPattern::Exact("--ext-diff"), + GitOptionPattern::Exact("--textconv"), + GitOptionPattern::Exact("--exec"), + GitOptionPattern::Prefix("--exec="), +]; + +impl GitOptionPattern { + fn matches(self, arg: &str) -> bool { + match self { + GitOptionPattern::Exact(option) => arg == option, + GitOptionPattern::ShortWithInlineValue(option) => { + arg.starts_with(option) && arg.len() > option.len() + } + GitOptionPattern::Prefix(prefix) => arg.starts_with(prefix), + } + } +} + +fn git_matches_option_pattern(arg: &str, patterns: &[GitOptionPattern]) -> bool { + patterns.iter().any(|pattern| pattern.matches(arg)) +} + +fn git_has_unsafe_global_option(global_args: &[String]) -> bool { + global_args .iter() - .skip(1) .map(String::as_str) - .any(git_global_option_requires_prompt) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_GLOBAL_OPTIONS)) } fn git_subcommand_args_are_read_only(args: &[String]) -> bool { - // Flags that can write to disk or execute external tools should never be - // auto-approved on an unsandboxed machine. - const UNSAFE_GIT_FLAGS: &[&str] = &[ - "--output", - "--ext-diff", - "--textconv", - "--exec", - "--paginate", - ]; - - !args.iter().map(String::as_str).any(|arg| { - UNSAFE_GIT_FLAGS.contains(&arg) - || arg.starts_with("--output=") - || arg.starts_with("--exec=") - }) + !args + .iter() + .map(String::as_str) + .any(|arg| git_matches_option_pattern(arg, UNSAFE_GIT_SUBCOMMAND_OPTIONS)) } // (bash parsing helpers implemented in crate::bash) @@ -395,6 +433,43 @@ mod tests { ]))); } + #[test] + fn git_global_pagination_flags_are_not_safe() { + assert!(!is_known_safe_command(&vec_str(&[ + "git", + "--paginate", + "log", + "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "git", "-p", "log", "-1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git --paginate log -1", + ]))); + assert!(!is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git -p log -1", + ]))); + } + + #[test] + fn git_subcommand_patch_flags_remain_safe() { + assert!(is_known_safe_command(&vec_str(&["git", "log", "-p", "-1"]))); + assert!(is_known_safe_command(&vec_str(&["git", "diff", "-p"]))); + assert!(is_known_safe_command(&vec_str(&[ + "git", "show", "-p", "HEAD", + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "git log -p -1", + ]))); + } + #[test] fn git_global_override_flags_are_not_safe() { assert!(!is_known_safe_command(&vec_str(&[ diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index ebe0058f75..8ef3f8e8f9 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -373,7 +373,6 @@ mod tests { "git diff --output codex_poc.txt", "git diff --ext-diff HEAD", "git log --textconv -1", - "git log --paginate -1", "git show --output=codex_poc.txt HEAD", "git cat-file --filters HEAD:a.txt", ] @@ -396,7 +395,6 @@ mod tests { ("git diff --output codex_poc.txt", false), ("git diff --ext-diff HEAD", false), ("git log --textconv -1", false), - ("git log --paginate -1", false), ("git show --output=codex_poc.txt HEAD", false), ("git cat-file --filters HEAD:a.txt", false), ], From 6b7d6cafa046077985800a09aa9ad4ad5a594c5c Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 6 May 2026 16:03:11 -0300 Subject: [PATCH 28/30] fix(tui): persist ctrl-c draft via app event (#21397) ## Why The main branch started failing after #21351 merged because the merge commit kept calling `AppCommand::add_to_history` from `BottomPane::clear_composer_for_ctrl_c`, but main had already removed that helper as part of the history persistence refactor. The PR head passed because it was based on an older main commit where the helper still existed. This restores the Ctrl+C draft-stashing behavior using the current app-event path instead of the removed command helper. ## What Changed - Store the active `ThreadId` in `BottomPane` when history metadata is provided. - Emit `AppEvent::AppendMessageHistoryEntry` for Ctrl+C-cleared drafts. - Update the slash-clear regression test to assert the current history event shape. ## How to Test Targeted tests: - `cargo test -p codex-tui slash_clear_after_ctrl_c_keeps_stashed_draft_recallable` Broader local checks: - `just fix -p codex-tui` - `just argument-comment-lint -p codex-tui` - `git diff --check origin/main...HEAD` - `cargo test -p codex-tui` reached completion; the fixed test passed, and the only local failures were `status::tests::status_permissions_full_disk_managed_*`, blocked by this machine config rejecting `DangerFullAccess` via `/etc/codex/requirements.toml`. --- codex-rs/tui/src/bottom_pane/mod.rs | 14 +++++++++++--- .../tui/src/chatwidget/tests/slash_commands.rs | 11 +++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 6f804c44ff..71075f3d06 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -17,7 +17,6 @@ use std::collections::VecDeque; use std::path::PathBuf; use crate::app::app_server_requests::ResolvedAppServerRequest; -use crate::app_command::AppCommand; use crate::app_event::AppEvent; use crate::app_event::ConnectorsSnapshot; use crate::app_event_sender::AppEventSender; @@ -209,6 +208,7 @@ pub(crate) struct BottomPane { app_event_tx: AppEventSender, frame_requester: FrameRequester, + thread_id: Option, has_input_focus: bool, enhanced_keys_supported: bool, @@ -274,6 +274,7 @@ impl BottomPane { last_composer_activity_at: None, app_event_tx, frame_requester, + thread_id: None, has_input_focus, enhanced_keys_supported, disable_paste_burst, @@ -780,8 +781,14 @@ impl BottomPane { pub(crate) fn clear_composer_for_ctrl_c(&mut self) { if let Some(text) = self.composer.clear_for_ctrl_c() { - self.app_event_tx - .send(AppEvent::CodexOp(AppCommand::add_to_history(text))); + if let Some(thread_id) = self.thread_id { + self.app_event_tx + .send(AppEvent::AppendMessageHistoryEntry { thread_id, text }); + } else { + tracing::warn!( + "failed to append Ctrl+C-cleared draft to history: no active thread id" + ); + } } self.request_redraw(); } @@ -1438,6 +1445,7 @@ impl BottomPane { log_id: u64, entry_count: usize, ) { + self.thread_id = Some(thread_id); self.composer .set_history_metadata(thread_id, log_id, entry_count); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 1bb4684c94..87ad226b51 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1584,7 +1584,13 @@ async fn slash_clear_requests_ui_clear_when_idle() { #[tokio::test] async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let thread_id = ThreadId::new(); + chat.thread_id = Some(thread_id); + chat.bottom_pane + .set_history_metadata(thread_id, /*log_id*/ 1, /*entry_count*/ 0); + submit_composer_text(&mut chat, "ok"); + assert_eq!(next_add_to_history_event(&mut rx), "ok"); let stashed_draft = "explain why history recall lost this draft"; @@ -1592,10 +1598,7 @@ async fn slash_clear_after_ctrl_c_keeps_stashed_draft_recallable() { .set_composer_text(stashed_draft.to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); assert_eq!(chat.bottom_pane.composer_text(), ""); - assert_matches!( - rx.try_recv(), - Ok(AppEvent::CodexOp(Op::AddToHistory { text })) if text == stashed_draft - ); + assert_eq!(next_add_to_history_event(&mut rx), stashed_draft); chat.bottom_pane .set_composer_text("/clear".to_string(), Vec::new(), Vec::new()); From 04ae865e76e43e38bd529702ffcf5c9e448ee1f0 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 6 May 2026 12:22:50 -0700 Subject: [PATCH 29/30] support rewrites for legacy shell handlers --- codex-rs/core/src/tools/handlers/shell.rs | 45 ++++++++ .../core/src/tools/handlers/shell_tests.rs | 103 ++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 45365dfbfd..0bbf06f36b 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -89,6 +89,35 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { .map(|params| params.command) } +// Hooks expose legacy function shell tools as joined command strings, while +// their function payload stores argv. Split on the way back in to invert the +// hook-facing representation. +fn rewrite_shell_function_updated_hook_input( + mut invocation: ToolInvocation, + updated_input: JsonValue, + tool_name: &str, +) -> Result { + let ToolPayload::Function { arguments } = invocation.payload else { + return Err(FunctionCallError::RespondToModel(format!( + "hook input rewrite received unsupported {tool_name} payload" + ))); + }; + let command = shlex::split(updated_hook_command(&updated_input)?).ok_or_else(|| { + FunctionCallError::RespondToModel( + "hook returned shell input with an invalid command string".to_string(), + ) + })?; + invocation.payload = ToolPayload::Function { + arguments: rewrite_function_arguments(&arguments, tool_name, |arguments| { + arguments.insert( + "command".to_string(), + JsonValue::Array(command.into_iter().map(JsonValue::String).collect()), + ); + })?, + }; + Ok(invocation) +} + struct RunExecLikeArgs { tool_name: String, exec_params: ExecParams, @@ -222,6 +251,14 @@ impl ToolHandler for ShellHandler { shell_function_pre_tool_use_payload(invocation) } + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: JsonValue, + ) -> Result { + rewrite_shell_function_updated_hook_input(invocation, updated_input, "shell") + } + fn post_tool_use_payload( &self, invocation: &ToolInvocation, @@ -300,6 +337,14 @@ impl ToolHandler for ContainerExecHandler { shell_function_pre_tool_use_payload(invocation) } + fn with_updated_hook_input( + &self, + invocation: ToolInvocation, + updated_input: JsonValue, + ) -> Result { + rewrite_shell_function_updated_hook_input(invocation, updated_input, "container.exec") + } + fn post_tool_use_payload( &self, invocation: &ToolInvocation, diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 8a32e5404b..4f44b953f4 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use codex_protocol::models::ShellCommandToolCallParams; +use codex_protocol::models::ShellToolCallParams; use core_test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -16,8 +17,10 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::handlers::ContainerExecHandler; use crate::tools::handlers::LocalShellHandler; use crate::tools::handlers::ShellCommandHandler; +use crate::tools::handlers::ShellHandler; use crate::tools::hook_names::HookToolName; use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; @@ -269,6 +272,106 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { ); } +#[tokio::test] +async fn shell_handler_rewrites_hook_command_back_to_argv() { + let payload = ToolPayload::Function { + arguments: json!({ + "command": ["bash", "-lc", "printf old"], + "workdir": "subdir", + }) + .to_string(), + }; + let (session, turn) = make_session_and_context().await; + let handler = ShellHandler; + + let invocation = handler + .with_updated_hook_input( + ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-43".to_string(), + tool_name: codex_tools::ToolName::plain("shell"), + source: ToolCallSource::Direct, + payload, + }, + json!({ "command": "bash -lc 'printf new'" }), + ) + .expect("shell rewrite should succeed"); + + let ToolPayload::Function { arguments } = invocation.payload else { + panic!("shell rewrite should preserve a function payload"); + }; + assert_eq!( + serde_json::from_str::(&arguments) + .expect("rewritten shell arguments should parse"), + ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf new".to_string(), + ], + workdir: Some("subdir".to_string()), + timeout_ms: None, + sandbox_permissions: None, + additional_permissions: None, + prefix_rule: None, + justification: None, + } + ); +} + +#[tokio::test] +async fn container_exec_handler_rewrites_hook_command_back_to_argv() { + let payload = ToolPayload::Function { + arguments: json!({ + "command": ["bash", "-lc", "printf old"], + "workdir": "subdir", + }) + .to_string(), + }; + let (session, turn) = make_session_and_context().await; + let handler = ContainerExecHandler; + + let invocation = handler + .with_updated_hook_input( + ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-44".to_string(), + tool_name: codex_tools::ToolName::plain("container.exec"), + source: ToolCallSource::Direct, + payload, + }, + json!({ "command": "bash -lc 'printf new'" }), + ) + .expect("container.exec rewrite should succeed"); + + let ToolPayload::Function { arguments } = invocation.payload else { + panic!("container.exec rewrite should preserve a function payload"); + }; + assert_eq!( + serde_json::from_str::(&arguments) + .expect("rewritten container.exec arguments should parse"), + ShellToolCallParams { + command: vec![ + "bash".to_string(), + "-lc".to_string(), + "printf new".to_string(), + ], + workdir: Some("subdir".to_string()), + timeout_ms: None, + sandbox_permissions: None, + additional_permissions: None, + prefix_rule: None, + justification: None, + } + ); +} + #[tokio::test] async fn build_post_tool_use_payload_uses_tool_output_wire_value() { let payload = ToolPayload::Function { From 786882a0bcd499e95d509bf3f87e5bb184e432bf Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Wed, 6 May 2026 13:05:15 -0700 Subject: [PATCH 30/30] Log rewritten tool payloads --- codex-rs/core/src/tools/registry.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index eb9ede7cb9..4547fe8ebf 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -294,8 +294,6 @@ impl ToolRegistry { let display_name = tool_name.display(); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); - let payload_for_response = invocation.payload.clone(); - let log_payload = payload_for_response.log_payload(); let metric_tags = [ ( "sandbox", @@ -343,6 +341,7 @@ impl ToolRegistry { Some(handler) => handler, None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); + let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( &display_name, &call_id_owned, @@ -362,6 +361,7 @@ impl ToolRegistry { if !handler.matches_kind(&invocation.payload) { let message = format!("tool {display_name} invoked with incompatible payload"); + let log_payload = invocation.payload.log_payload(); otel.tool_result_with_tags( &display_name, &call_id_owned, @@ -409,6 +409,7 @@ impl ToolRegistry { let invocation_for_tool = invocation.clone(); let started = Instant::now(); + let log_payload = invocation.payload.log_payload(); let result = otel .log_tool_result_with_tags( &display_name,