diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index a6eb901a5..ae8e6fed3 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2881,6 +2881,22 @@ ], "type": "object" }, + "ThreadShellCommandParams": { + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "type": "object" + }, "ThreadSortKey": { "enum": [ "created_at", @@ -3586,6 +3602,30 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/shellCommand" + ], + "title": "Thread/shellCommandRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadShellCommandParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/shellCommandRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 3a6babc78..b2daefa84 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -745,6 +745,15 @@ ], "type": "object" }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -2390,6 +2399,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 bbbf7810b..189ffc03e 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 @@ -499,6 +499,30 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/shellCommand" + ], + "title": "Thread/shellCommandRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadShellCommandParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/shellCommandRequest", + "type": "object" + }, { "properties": { "id": { @@ -6121,6 +6145,15 @@ "title": "CommandExecutionOutputDeltaNotification", "type": "object" }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -12063,6 +12096,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/v2/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/v2/CommandExecutionStatus" }, @@ -13102,6 +13143,29 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadShellCommandParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" + }, + "ThreadShellCommandResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" + }, "ThreadSortKey": { "enum": [ "created_at", 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 496e7f139..f39ea38a7 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 @@ -1026,6 +1026,30 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/shellCommand" + ], + "title": "Thread/shellCommandRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadShellCommandParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/shellCommandRequest", + "type": "object" + }, { "properties": { "id": { @@ -2754,6 +2778,15 @@ "title": "CommandExecutionOutputDeltaNotification", "type": "object" }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -9823,6 +9856,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, @@ -10862,6 +10903,29 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadShellCommandParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" + }, + "ThreadShellCommandResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" + }, "ThreadSortKey": { "enum": [ "created_at", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index f165850bf..f3505efe6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -177,6 +177,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -642,6 +651,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 811e02c5a..cfb2fa930 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -177,6 +177,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -642,6 +651,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index aeb4db80e..9ecf2f399 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -756,6 +765,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 04765cf48..6686ce226 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -353,6 +353,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -1236,6 +1245,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 936630400..35113ffb9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 57dea225e..2479a855d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 295938ba8..bcf466be3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 774c3cade..9525bed93 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -353,6 +353,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -1236,6 +1245,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 518f560a2..defb8f42c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json new file mode 100644 index 000000000..13ef468a5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandParams.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "description": "Shell command string evaluated by the thread's configured shell. Unlike `command/exec`, this intentionally preserves shell syntax such as pipes, redirects, and quoting. This runs unsandboxed with full access rather than inheriting the thread sandbox policy.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "command", + "threadId" + ], + "title": "ThreadShellCommandParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json new file mode 100644 index 000000000..06e9d81a3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadShellCommandResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadShellCommandResponse", + "type": "object" +} \ No newline at end of file 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 a6746e1eb..1a4e66089 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -353,6 +353,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -1236,6 +1245,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 a2307578d..c391f2800 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, 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 64c00271f..ec5e2a6e7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -994,6 +1003,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 163d22b64..0a1527f4f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -756,6 +765,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 9264d98d2..b7accf4c2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -756,6 +765,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 5ed40f55f..6653cc81d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -291,6 +291,15 @@ } ] }, + "CommandExecutionSource": { + "enum": [ + "agent", + "userShell", + "unifiedExecStartup", + "unifiedExecInteraction" + ], + "type": "string" + }, "CommandExecutionStatus": { "enum": [ "inProgress", @@ -756,6 +765,14 @@ "null" ] }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, "status": { "$ref": "#/definitions/CommandExecutionStatus" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index b854afd66..5e03a26ca 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -49,6 +49,7 @@ import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; import type { ThreadRollbackParams } from "./v2/ThreadRollbackParams"; import type { ThreadSetNameParams } from "./v2/ThreadSetNameParams"; +import type { ThreadShellCommandParams } from "./v2/ThreadShellCommandParams"; import type { ThreadStartParams } from "./v2/ThreadStartParams"; import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams"; import type { ThreadUnsubscribeParams } from "./v2/ThreadUnsubscribeParams"; @@ -60,4 +61,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/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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "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/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": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "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": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "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/CommandExecutionSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionSource.ts new file mode 100644 index 000000000..9432841fb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionSource.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 CommandExecutionSource = "agent" | "userShell" | "unifiedExecStartup" | "unifiedExecInteraction"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 51ab9e881..280f862a3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -8,6 +8,7 @@ import type { CollabAgentState } from "./CollabAgentState"; import type { CollabAgentTool } from "./CollabAgentTool"; import type { CollabAgentToolCallStatus } from "./CollabAgentToolCallStatus"; import type { CommandAction } from "./CommandAction"; +import type { CommandExecutionSource } from "./CommandExecutionSource"; import type { CommandExecutionStatus } from "./CommandExecutionStatus"; import type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; import type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; @@ -32,7 +33,7 @@ cwd: string, /** * Identifier for the underlying PTY process (when available). */ -processId: string | null, status: CommandExecutionStatus, +processId: string | null, source: CommandExecutionSource, status: CommandExecutionStatus, /** * A best-effort parsing of the command to understand the action(s) it will perform. * This returns a list of CommandAction objects because a single shell command may diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts new file mode 100644 index 000000000..8c50612ca --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandParams.ts @@ -0,0 +1,12 @@ +// 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 ThreadShellCommandParams = { threadId: string, +/** + * Shell command string evaluated by the thread's configured shell. + * Unlike `command/exec`, this intentionally preserves shell syntax + * such as pipes, redirects, and quoting. This runs unsandboxed with full + * access rather than inheriting the thread sandbox policy. + */ +command: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.ts new file mode 100644 index 000000000..9c54b4583 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadShellCommandResponse.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 ThreadShellCommandResponse = Record; 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 09ca04675..3dcf98ae3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -55,6 +55,7 @@ export type { CommandExecutionOutputDeltaNotification } from "./CommandExecution export type { CommandExecutionRequestApprovalParams } from "./CommandExecutionRequestApprovalParams"; export type { CommandExecutionRequestApprovalResponse } from "./CommandExecutionRequestApprovalResponse"; export type { CommandExecutionRequestApprovalSkillMetadata } from "./CommandExecutionRequestApprovalSkillMetadata"; +export type { CommandExecutionSource } from "./CommandExecutionSource"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; export type { Config } from "./Config"; export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams"; @@ -283,6 +284,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; +export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5df79060c..0726dfd77 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -267,6 +267,10 @@ client_request_definitions! { params: v2::ThreadCompactStartParams, response: v2::ThreadCompactStartResponse, }, + ThreadShellCommand => "thread/shellCommand" { + params: v2::ThreadShellCommandParams, + response: v2::ThreadShellCommandResponse, + }, #[experimental("thread/backgroundTerminals/clean")] ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" { params: v2::ThreadBackgroundTerminalsCleanParams, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index e46cc0307..128e2a3ce 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -341,6 +341,7 @@ impl ThreadHistoryBuilder { command, cwd: payload.cwd.clone(), process_id: payload.process_id.clone(), + source: payload.source.into(), status: CommandExecutionStatus::InProgress, command_actions, aggregated_output: None, @@ -371,6 +372,7 @@ impl ThreadHistoryBuilder { command, cwd: payload.cwd.clone(), process_id: payload.process_id.clone(), + source: payload.source.into(), status, command_actions, aggregated_output, @@ -1144,6 +1146,7 @@ impl From<&PendingTurn> for Turn { #[cfg(test)] mod tests { use super::*; + use crate::protocol::v2::CommandExecutionSource; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::items::TurnItem as CoreTurnItem; @@ -1745,6 +1748,7 @@ mod tests { command: "echo 'hello world'".into(), cwd: PathBuf::from("/tmp"), process_id: Some("pid-1".into()), + source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { command: "echo hello world".into(), @@ -1893,6 +1897,7 @@ mod tests { command: "ls".into(), cwd: PathBuf::from("/tmp"), process_id: Some("pid-2".into()), + source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Declined, command_actions: vec![CommandAction::Unknown { command: "ls".into(), @@ -1987,6 +1992,7 @@ mod tests { command: "echo done".into(), cwd: PathBuf::from("/tmp"), process_id: Some("pid-42".into()), + source: CommandExecutionSource::Agent, status: CommandExecutionStatus::Completed, command_actions: vec![CommandAction::Unknown { command: "echo done".into(), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 25a035cac..2138330e3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -52,6 +52,7 @@ use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; @@ -92,6 +93,7 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +use serde_with::serde_as; use thiserror::Error; use ts_rs::TS; @@ -2871,6 +2873,23 @@ pub struct ThreadCompactStartParams { #[ts(export_to = "v2/")] pub struct ThreadCompactStartResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandParams { + pub thread_id: String, + /// Shell command string evaluated by the thread's configured shell. + /// Unlike `command/exec`, this intentionally preserves shell syntax + /// such as pipes, redirects, and quoting. This runs unsandboxed with full + /// access rather than inheriting the thread sandbox policy. + pub command: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4137,6 +4156,8 @@ pub enum ThreadItem { cwd: PathBuf, /// Identifier for the underlying PTY process (when available). process_id: Option, + #[serde(default)] + source: CommandExecutionSource, status: CommandExecutionStatus, /// A best-effort parsing of the command to understand the action(s) it will perform. /// This returns a list of CommandAction objects because a single shell command may @@ -4417,6 +4438,17 @@ impl From<&CoreExecCommandStatus> for CommandExecutionStatus { } } +v2_enum_from_core! { + #[derive(Default)] + pub enum CommandExecutionSource from CoreExecCommandSource { + #[default] + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4863,6 +4895,7 @@ pub struct TerminalInteractionNotification { pub stdin: String, } +#[serde_as] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6313,6 +6346,40 @@ mod tests { assert_eq!(decoded, params); } + #[test] + fn thread_shell_command_params_round_trip() { + let params = ThreadShellCommandParams { + thread_id: "thr_123".to_string(), + command: "printf 'hello world\\n'".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + "command": "printf 'hello world\\n'", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand params"); + assert_eq!(decoded, params); + } + + #[test] + fn thread_shell_command_response_round_trip() { + let response = ThreadShellCommandResponse {}; + + let value = + serde_json::to_value(&response).expect("serialize thread/shellCommand response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand response"); + assert_eq!(decoded, response); + } + #[test] fn command_exec_params_default_optional_streaming_flags() { let params = serde_json::from_value::(json!({ @@ -6607,6 +6674,32 @@ mod tests { assert_eq!(decoded, notification); } + #[test] + fn command_execution_output_delta_round_trips() { + let notification = CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "\u{fffd}a\n".to_string(), + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize item/commandExecution/outputDelta notification"); + assert_eq!( + value, + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "delta": "\u{fffd}a\n", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); + } + #[test] fn sandbox_policy_round_trips_external_sandbox_network_access() { let v2_policy = SandboxPolicy::ExternalSandbox { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e62db6439..57798a99b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -136,6 +136,7 @@ Example with notification opt-out: - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. +- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". @@ -415,6 +416,31 @@ While compaction is running, the thread is effectively in a turn so clients shou { "id": 25, "result": {} } ``` +### Example: Run a thread shell command + +Use `thread/shellCommand` for the TUI `!` workflow. The request returns immediately with `{}`. +This API runs unsandboxed with full access; it does not inherit the thread +sandbox policy. + +If the thread already has an active turn, the command runs as an auxiliary action on that turn. In that case, progress is emitted as standard `item/*` notifications on the existing turn and the formatted output is injected into the turn’s message stream: + +- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }` +- zero or more `item/commandExecution/outputDelta` +- `item/completed` with the same `commandExecution` item id + +If the thread does not already have an active turn, the server starts a standalone turn for the shell command. In that case clients should expect: + +- `turn/started` +- `item/started` with `item: { "type": "commandExecution", "source": "userShell", ... }` +- zero or more `item/commandExecution/outputDelta` +- `item/completed` with the same `commandExecution` item id +- `turn/completed` + +```json +{ "method": "thread/shellCommand", "id": 26, "params": { "threadId": "thr_b", "command": "git status --short" } } +{ "id": 26, "result": {} } +``` + ### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8a6b48a47..780c06a52 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -27,6 +27,7 @@ use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; use codex_app_server_protocol::CommandExecutionRequestApprovalParams; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionRequestApprovalSkillMetadata; +use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::DeprecationNoticeNotification; @@ -1563,6 +1564,7 @@ pub(crate) async fn apply_bespoke_event_handling( command, cwd, process_id, + source: exec_command_begin_event.source.into(), status: CommandExecutionStatus::InProgress, command_actions, aggregated_output: None, @@ -1580,7 +1582,6 @@ pub(crate) async fn apply_bespoke_event_handling( } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { let item_id = exec_command_output_delta_event.call_id.clone(); - let delta = String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(); // The underlying EventMsg::ExecCommandOutputDelta is used for shell, unified_exec, // and apply_patch tool calls. We represent apply_patch with the FileChange item, and // everything else with the CommandExecution item. @@ -1592,6 +1593,8 @@ pub(crate) async fn apply_bespoke_event_handling( state.turn_summary.file_change_started.contains(&item_id) }; if is_file_change { + let delta = + String::from_utf8_lossy(&exec_command_output_delta_event.chunk).to_string(); let notification = FileChangeOutputDeltaNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), @@ -1608,7 +1611,8 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), item_id, - delta, + delta: String::from_utf8_lossy(&exec_command_output_delta_event.chunk) + .to_string(), }; outgoing .send_server_notification(ServerNotification::CommandExecutionOutputDelta( @@ -1641,6 +1645,7 @@ pub(crate) async fn apply_bespoke_event_handling( aggregated_output, exit_code, duration, + source, status, .. } = exec_command_end_event; @@ -1672,6 +1677,7 @@ pub(crate) async fn apply_bespoke_event_handling( command: shlex_join(&command), cwd, process_id, + source: source.into(), status, command_actions, aggregated_output, @@ -1935,6 +1941,7 @@ async fn complete_command_execution_item( command: String, cwd: PathBuf, process_id: Option, + source: CommandExecutionSource, command_actions: Vec, status: CommandExecutionStatus, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1944,6 +1951,7 @@ async fn complete_command_execution_item( command, cwd, process_id, + source, status, command_actions, aggregated_output: None, @@ -2607,6 +2615,7 @@ async fn on_command_execution_request_approval_response( completion_item.command, completion_item.cwd, /*process_id*/ None, + CommandExecutionSource::Agent, completion_item.command_actions, status, &outgoing, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index deee837fe..37f9d3ffe 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -146,6 +146,8 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -695,6 +697,10 @@ impl CodexMessageProcessor { self.thread_read(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadShellCommand { request_id, params } => { + self.thread_shell_command(to_connection_request_id(request_id), params) + .await; + } ClientRequest::SkillsList { request_id, params } => { self.skills_list(to_connection_request_id(request_id), params) .await; @@ -2974,6 +2980,58 @@ impl CodexMessageProcessor { } } + async fn thread_shell_command( + &self, + request_id: ConnectionRequestId, + params: ThreadShellCommandParams, + ) { + let ThreadShellCommandParams { thread_id, command } = params; + let command = command.trim().to_string(); + if command.is_empty() { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "command must not be empty".to_string(), + data: None, + }, + ) + .await; + return; + } + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op( + &request_id, + thread.as_ref(), + Op::RunUserShellCommand { command }, + ) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadShellCommandResponse {}) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to start shell command: {err}"), + ) + .await; + } + } + } + async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) { let ThreadListParams { cursor, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 430a400a2..5752fd336 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -68,6 +68,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -386,6 +387,15 @@ impl McpProcess { self.send_request("thread/compact/start", params).await } + /// Send a `thread/shellCommand` JSON-RPC request. + pub async fn send_thread_shell_command_request( + &mut self, + params: ThreadShellCommandParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/shellCommand", params).await + } + /// Send a `thread/rollback` JSON-RPC request. pub async fn send_thread_rollback_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 7fa5520a2..b4e24ebe2 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -38,6 +38,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_shell_command; mod thread_start; mod thread_status; mod thread_unarchive; diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs new file mode 100644 index 000000000..e6dd21796 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -0,0 +1,439 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_shell_command_sse_response; +use app_test_support::format_with_current_shell_display; +use app_test_support::to_response; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::features::FEATURES; +use codex_core::features::Feature; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let server = create_mock_responses_server_sequence(vec![]).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id.clone(), + command: "printf 'hello from bang\\n'".to_string(), + }) + .await?; + let shell_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(shell_id)), + ) + .await??; + let _: ThreadShellCommandResponse = to_response::(shell_resp)?; + + let started = wait_for_command_execution_started(&mut mcp, None).await?; + let ThreadItem::CommandExecution { + id, source, status, .. + } = &started.item + else { + unreachable!("helper returns command execution item"); + }; + let command_id = id.clone(); + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(status, &CommandExecutionStatus::InProgress); + + let delta = wait_for_command_execution_output_delta(&mut mcp, &command_id).await?; + assert_eq!(delta.delta, "hello from bang\n"); + + let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; + let ThreadItem::CommandExecution { + id, + source, + status, + aggregated_output, + exit_code, + .. + } = &completed.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(id, &command_id); + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(status, &CommandExecutionStatus::Completed); + assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n")); + assert_eq!(*exit_code, Some(0)); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread } = to_response::(read_resp)?; + assert_eq!(thread.turns.len(), 1); + let ThreadItem::CommandExecution { + source, + status, + aggregated_output, + .. + } = thread.turns[0] + .items + .iter() + .find(|item| matches!(item, ThreadItem::CommandExecution { .. })) + .expect("expected persisted command execution item") + else { + unreachable!("matched command execution item"); + }; + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(status, &CommandExecutionStatus::Completed); + assert_eq!(aggregated_output.as_deref(), Some("hello from bang\n")); + + Ok(()) +} + +#[tokio::test] +async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call-approve", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_responses_server_sequence(responses).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "untrusted", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?; + let ThreadItem::CommandExecution { + command, source, .. + } = &agent_started.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(source, &CommandExecutionSource::Agent); + assert_eq!( + command, + &format_with_current_shell_display("python3 -c 'print(42)'") + ); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, .. } = server_req else { + panic!("expected approval request"); + }; + + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id.clone(), + command: "printf 'active turn bang\\n'".to_string(), + }) + .await?; + let shell_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(shell_id)), + ) + .await??; + let _: ThreadShellCommandResponse = to_response::(shell_resp)?; + + let started = + wait_for_command_execution_started_by_source(&mut mcp, CommandExecutionSource::UserShell) + .await?; + assert_eq!(started.turn_id, turn.id); + let command_id = match &started.item { + ThreadItem::CommandExecution { id, .. } => id.clone(), + _ => unreachable!("helper returns command execution item"), + }; + let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?; + assert_eq!(completed.turn_id, turn.id); + let ThreadItem::CommandExecution { + source, + aggregated_output, + .. + } = &completed.item + else { + unreachable!("helper returns command execution item"); + }; + assert_eq!(source, &CommandExecutionSource::UserShell); + assert_eq!(aggregated_output.as_deref(), Some("active turn bang\n")); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Decline, + })?, + ) + .await?; + let _: TurnCompletedNotification = serde_json::from_value( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await?? + .params + .expect("turn/completed params"), + )?; + + let read_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread.id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadReadResponse { thread } = to_response::(read_resp)?; + assert_eq!(thread.turns.len(), 1); + assert!( + thread.turns[0].items.iter().any(|item| { + matches!( + item, + ThreadItem::CommandExecution { + source: CommandExecutionSource::UserShell, + aggregated_output, + .. + } if aggregated_output.as_deref() == Some("active turn bang\n") + ) + }), + "expected active-turn shell command to be persisted on the existing turn" + ); + + Ok(()) +} + +async fn wait_for_command_execution_started( + mcp: &mut McpProcess, + expected_id: Option<&str>, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing item/started params"))?, + )?; + let ThreadItem::CommandExecution { id, .. } = &started.item else { + continue; + }; + if expected_id.is_none() || expected_id == Some(id.as_str()) { + return Ok(started); + } + } +} + +async fn wait_for_command_execution_started_by_source( + mcp: &mut McpProcess, + expected_source: CommandExecutionSource, +) -> Result { + loop { + let started = wait_for_command_execution_started(mcp, None).await?; + let ThreadItem::CommandExecution { source, .. } = &started.item else { + continue; + }; + if source == &expected_source { + return Ok(started); + } + } +} + +async fn wait_for_command_execution_completed( + mcp: &mut McpProcess, + expected_id: Option<&str>, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing item/completed params"))?, + )?; + let ThreadItem::CommandExecution { id, .. } = &completed.item else { + continue; + }; + if expected_id.is_none() || expected_id == Some(id.as_str()) { + return Ok(completed); + } + } +} + +async fn wait_for_command_execution_output_delta( + mcp: &mut McpProcess, + item_id: &str, +) -> Result { + loop { + let notif = mcp + .read_stream_until_notification_message("item/commandExecution/outputDelta") + .await?; + let delta: CommandExecutionOutputDeltaNotification = serde_json::from_value( + notif + .params + .ok_or_else(|| anyhow::anyhow!("missing output delta params"))?, + )?; + if delta.item_id == item_id { + return Ok(delta); + } + } +} + +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, + feature_flags: &BTreeMap, +) -> std::io::Result<()> { + let feature_entries = feature_flags + .iter() + .map(|(feature, enabled)| { + let key = FEATURES + .iter() + .find(|spec| spec.id == *feature) + .map(|spec| spec.key) + .unwrap_or_else(|| panic!("missing feature key for {feature:?}")); + format!("{key} = {enabled}") + }) + .collect::>() + .join("\n"); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[features] +{feature_entries} + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 83368efc9..77c2711b5 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -331,6 +331,9 @@ async fn persist_user_shell_output( session .record_conversation_items(turn_context, std::slice::from_ref(&output_item)) .await; + // Standalone shell turns can run before any regular user turn, so + // explicitly materialize rollout persistence after recording output. + session.ensure_rollout_materialized().await; return; } diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 4b52609db..8f8092eac 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -2050,6 +2050,12 @@ impl App { app_server.thread_realtime_stop(thread_id).await?; Ok(true) } + AppCommandView::RunUserShellCommand { command } => { + app_server + .thread_shell_command(thread_id, command.to_string()) + .await?; + Ok(true) + } AppCommandView::OverrideTurnContext { .. } => Ok(true), _ => Ok(false), } diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index a2e83092c..262cd4216 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -1,3 +1,16 @@ +/* +This module holds the temporary adapter layer between the TUI and the app +server during the hybrid migration period. + +For now, the TUI still owns its existing direct-core behavior, but startup +allocates a local in-process app server and drains its event stream. Keeping +the app-server-specific wiring here keeps that transitional logic out of the +main `app.rs` orchestration path. + +As more TUI flows move onto the app-server surface directly, this adapter +should shrink and eventually disappear. +*/ + use super::App; use crate::app_event::AppEvent; use crate::app_server_session::AppServerSession; @@ -12,8 +25,90 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +#[cfg(test)] +use codex_app_server_protocol::Thread; +#[cfg(test)] +use codex_app_server_protocol::ThreadItem; +#[cfg(test)] +use codex_app_server_protocol::Turn; +#[cfg(test)] +use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; +#[cfg(test)] +use codex_protocol::config_types::ModeKind; +#[cfg(test)] +use codex_protocol::items::AgentMessageContent; +#[cfg(test)] +use codex_protocol::items::AgentMessageItem; +#[cfg(test)] +use codex_protocol::items::ContextCompactionItem; +#[cfg(test)] +use codex_protocol::items::ImageGenerationItem; +#[cfg(test)] +use codex_protocol::items::PlanItem; +#[cfg(test)] +use codex_protocol::items::ReasoningItem; +#[cfg(test)] +use codex_protocol::items::TurnItem; +#[cfg(test)] +use codex_protocol::items::UserMessageItem; +#[cfg(test)] +use codex_protocol::items::WebSearchItem; +#[cfg(test)] +use codex_protocol::protocol::AgentMessageDeltaEvent; +#[cfg(test)] +use codex_protocol::protocol::AgentReasoningDeltaEvent; +#[cfg(test)] +use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; +#[cfg(test)] +use codex_protocol::protocol::ErrorEvent; +#[cfg(test)] +use codex_protocol::protocol::Event; +#[cfg(test)] +use codex_protocol::protocol::EventMsg; +#[cfg(test)] +use codex_protocol::protocol::ExecCommandBeginEvent; +#[cfg(test)] +use codex_protocol::protocol::ExecCommandEndEvent; +#[cfg(test)] +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +#[cfg(test)] +use codex_protocol::protocol::ExecCommandStatus; +#[cfg(test)] +use codex_protocol::protocol::ExecOutputStream; +#[cfg(test)] +use codex_protocol::protocol::ItemCompletedEvent; +#[cfg(test)] +use codex_protocol::protocol::ItemStartedEvent; +#[cfg(test)] +use codex_protocol::protocol::PlanDeltaEvent; +#[cfg(test)] +use codex_protocol::protocol::RealtimeConversationClosedEvent; +#[cfg(test)] +use codex_protocol::protocol::RealtimeConversationRealtimeEvent; +#[cfg(test)] +use codex_protocol::protocol::RealtimeConversationStartedEvent; +#[cfg(test)] +use codex_protocol::protocol::RealtimeEvent; +#[cfg(test)] +use codex_protocol::protocol::ThreadNameUpdatedEvent; +#[cfg(test)] +use codex_protocol::protocol::TokenCountEvent; +#[cfg(test)] +use codex_protocol::protocol::TokenUsage; +#[cfg(test)] +use codex_protocol::protocol::TokenUsageInfo; +#[cfg(test)] +use codex_protocol::protocol::TurnAbortReason; +#[cfg(test)] +use codex_protocol::protocol::TurnAbortedEvent; +#[cfg(test)] +use codex_protocol::protocol::TurnCompleteEvent; +#[cfg(test)] +use codex_protocol::protocol::TurnStartedEvent; use serde_json::Value; +#[cfg(test)] +use std::time::Duration; #[derive(Debug, PartialEq, Eq)] enum LegacyThreadNotification { @@ -266,7 +361,7 @@ impl App { async fn reject_app_server_request( &self, app_server_client: &AppServerSession, - request_id: RequestId, + request_id: codex_app_server_protocol::RequestId, reason: String, ) -> std::result::Result<(), String> { app_server_client @@ -283,28 +378,6 @@ impl App { } } -fn resolve_chatgpt_auth_tokens_refresh_response( - codex_home: &std::path::Path, - auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, - forced_chatgpt_workspace_id: Option<&str>, - params: &ChatgptAuthTokensRefreshParams, -) -> Result { - let auth = load_local_chatgpt_auth( - codex_home, - auth_credentials_store_mode, - forced_chatgpt_workspace_id, - )?; - if let Some(previous_account_id) = params.previous_account_id.as_deref() - && previous_account_id != auth.chatgpt_account_id - { - return Err(format!( - "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", - auth.chatgpt_account_id - )); - } - Ok(auth.to_refresh_response()) -} - fn server_request_thread_id(request: &ServerRequest) -> Option { match request { ServerRequest::CommandExecutionRequestApproval { params, .. } => { @@ -442,6 +515,54 @@ fn server_notification_thread_target( } } +fn resolve_chatgpt_auth_tokens_refresh_response( + codex_home: &std::path::Path, + auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode, + forced_chatgpt_workspace_id: Option<&str>, + params: &ChatgptAuthTokensRefreshParams, +) -> Result { + let auth = load_local_chatgpt_auth( + codex_home, + auth_credentials_store_mode, + forced_chatgpt_workspace_id, + )?; + if let Some(previous_account_id) = params.previous_account_id.as_deref() + && previous_account_id != auth.chatgpt_account_id + { + return Err(format!( + "local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`", + auth.chatgpt_account_id + )); + } + Ok(auth.to_refresh_response()) +} + +#[cfg(test)] +/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s +/// suitable for replaying into the TUI event store. +/// +/// Each turn is expanded into `TurnStarted`, zero or more `ItemCompleted`, +/// and a terminal event that matches the turn's `TurnStatus`. Returns an +/// empty vec (with a warning log) if the thread ID is not a valid UUID. +pub(super) fn thread_snapshot_events( + thread: &Thread, + show_raw_agent_reasoning: bool, +) -> Vec { + let Ok(thread_id) = ThreadId::from_string(&thread.id) else { + tracing::warn!( + thread_id = %thread.id, + "ignoring app-server thread snapshot with invalid thread id" + ); + return Vec::new(); + }; + + thread + .turns + .iter() + .flat_map(|turn| turn_snapshot_events(thread_id, turn, show_raw_agent_reasoning)) + .collect() +} + fn legacy_thread_notification( notification: JSONRPCNotification, ) -> Option<(ThreadId, LegacyThreadNotification)> { @@ -484,20 +605,720 @@ fn legacy_thread_notification( } } +#[cfg(test)] +fn server_notification_thread_events( + notification: ServerNotification, +) -> Option<(ThreadId, Vec)> { + match notification { + ServerNotification::ThreadTokenUsageUpdated(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::TokenCount(TokenCountEvent { + info: Some(TokenUsageInfo { + total_token_usage: token_usage_from_app_server( + notification.token_usage.total, + ), + last_token_usage: token_usage_from_app_server( + notification.token_usage.last, + ), + model_context_window: notification.token_usage.model_context_window, + }), + rate_limits: None, + }), + }], + )), + ServerNotification::Error(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::Error(ErrorEvent { + message: notification.error.message, + codex_error_info: notification + .error + .codex_error_info + .and_then(app_server_codex_error_info_to_core), + }), + }], + )), + ServerNotification::ThreadNameUpdated(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::ThreadNameUpdated(ThreadNameUpdatedEvent { + thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, + thread_name: notification.thread_name, + }), + }], + )), + ServerNotification::TurnStarted(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: notification.turn.id, + model_context_window: None, + collaboration_mode_kind: ModeKind::default(), + }), + }], + )), + ServerNotification::TurnCompleted(notification) => { + let thread_id = ThreadId::from_string(¬ification.thread_id).ok()?; + let mut events = Vec::new(); + append_terminal_turn_events( + &mut events, + ¬ification.turn, + /*include_failed_error*/ false, + ); + Some((thread_id, events)) + } + ServerNotification::ItemStarted(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + command_execution_started_event(¬ification.turn_id, ¬ification.item).or_else( + || { + Some(vec![Event { + id: String::new(), + msg: EventMsg::ItemStarted(ItemStartedEvent { + thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, + turn_id: notification.turn_id.clone(), + item: thread_item_to_core(¬ification.item)?, + }), + }]) + }, + )?, + )), + ServerNotification::ItemCompleted(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + command_execution_completed_event(¬ification.turn_id, ¬ification.item).or_else( + || { + Some(vec![Event { + id: String::new(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: ThreadId::from_string(¬ification.thread_id).ok()?, + turn_id: notification.turn_id.clone(), + item: thread_item_to_core(¬ification.item)?, + }), + }]) + }, + )?, + )), + ServerNotification::CommandExecutionOutputDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: notification.item_id, + stream: ExecOutputStream::Stdout, + chunk: notification.delta.into_bytes(), + }), + }], + )), + ServerNotification::AgentMessageDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { + delta: notification.delta, + }), + }], + )), + ServerNotification::PlanDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::PlanDelta(PlanDeltaEvent { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + item_id: notification.item_id, + delta: notification.delta, + }), + }], + )), + ServerNotification::ReasoningSummaryTextDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { + delta: notification.delta, + }), + }], + )), + ServerNotification::ReasoningTextDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { + delta: notification.delta, + }), + }], + )), + ServerNotification::ThreadRealtimeStarted(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent { + session_id: notification.session_id, + version: notification.version, + }), + }], + )), + ServerNotification::ThreadRealtimeItemAdded(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ConversationItemAdded(notification.item), + }), + }], + )), + ServerNotification::ThreadRealtimeOutputAudioDelta(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::AudioOut(notification.audio.into()), + }), + }], + )), + ServerNotification::ThreadRealtimeError(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::Error(notification.message), + }), + }], + )), + ServerNotification::ThreadRealtimeClosed(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::RealtimeConversationClosed(RealtimeConversationClosedEvent { + reason: notification.reason, + }), + }], + )), + _ => None, + } +} + +#[cfg(test)] +fn token_usage_from_app_server( + value: codex_app_server_protocol::TokenUsageBreakdown, +) -> TokenUsage { + TokenUsage { + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + output_tokens: value.output_tokens, + reasoning_output_tokens: value.reasoning_output_tokens, + total_tokens: value.total_tokens, + } +} + +/// Expand a single `Turn` into the event sequence the TUI would have +/// observed if it had been connected for the turn's entire lifetime. +/// +/// Snapshot replay keeps committed-item semantics for user / plan / +/// agent-message items, while replaying the legacy events that still +/// drive rendering for reasoning, web-search, image-generation, and +/// context-compaction history cells. +#[cfg(test)] +fn turn_snapshot_events( + thread_id: ThreadId, + turn: &Turn, + show_raw_agent_reasoning: bool, +) -> Vec { + let mut events = vec![Event { + id: String::new(), + msg: EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn.id.clone(), + model_context_window: None, + collaboration_mode_kind: ModeKind::default(), + }), + }]; + + for item in &turn.items { + if let Some(command_events) = command_execution_snapshot_events(&turn.id, item) { + events.extend(command_events); + continue; + } + + let Some(item) = thread_item_to_core(item) else { + continue; + }; + match item { + TurnItem::UserMessage(_) | TurnItem::Plan(_) | TurnItem::AgentMessage(_) => { + events.push(Event { + id: String::new(), + msg: EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id, + turn_id: turn.id.clone(), + item, + }), + }); + } + TurnItem::Reasoning(_) + | TurnItem::WebSearch(_) + | TurnItem::ImageGeneration(_) + | TurnItem::ContextCompaction(_) => { + events.extend( + item.as_legacy_events(show_raw_agent_reasoning) + .into_iter() + .map(|msg| Event { + id: String::new(), + msg, + }), + ); + } + } + } + + append_terminal_turn_events(&mut events, turn, /*include_failed_error*/ true); + + events +} + +/// Append the terminal event(s) for a turn based on its `TurnStatus`. +/// +/// This function is shared between the live notification bridge +/// (`TurnCompleted` handling) and the snapshot replay path so that both +/// produce identical `EventMsg` sequences for the same turn status. +/// +/// - `Completed` → `TurnComplete` +/// - `Interrupted` → `TurnAborted { reason: Interrupted }` +/// - `Failed` → `Error` (if present) then `TurnComplete` +/// - `InProgress` → no events (the turn is still running) +#[cfg(test)] +fn append_terminal_turn_events(events: &mut Vec, turn: &Turn, include_failed_error: bool) { + match turn.status { + TurnStatus::Completed => events.push(Event { + id: String::new(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn.id.clone(), + last_agent_message: None, + }), + }), + TurnStatus::Interrupted => events.push(Event { + id: String::new(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some(turn.id.clone()), + reason: TurnAbortReason::Interrupted, + }), + }), + TurnStatus::Failed => { + if include_failed_error && let Some(error) = &turn.error { + events.push(Event { + id: String::new(), + msg: EventMsg::Error(ErrorEvent { + message: error.message.clone(), + codex_error_info: error + .codex_error_info + .clone() + .and_then(app_server_codex_error_info_to_core), + }), + }); + } + events.push(Event { + id: String::new(), + msg: EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn.id.clone(), + last_agent_message: None, + }), + }); + } + TurnStatus::InProgress => { + // Preserve unfinished turns during snapshot replay without emitting completion events. + } + } +} + +#[cfg(test)] +fn thread_item_to_core(item: &ThreadItem) -> Option { + match item { + ThreadItem::UserMessage { id, content } => Some(TurnItem::UserMessage(UserMessageItem { + id: id.clone(), + content: content + .iter() + .cloned() + .map(codex_app_server_protocol::UserInput::into_core) + .collect(), + })), + ThreadItem::AgentMessage { + id, + text, + phase, + memory_citation, + } => Some(TurnItem::AgentMessage(AgentMessageItem { + id: id.clone(), + content: vec![AgentMessageContent::Text { text: text.clone() }], + phase: phase.clone(), + memory_citation: memory_citation.clone().map(|citation| { + codex_protocol::memory_citation::MemoryCitation { + entries: citation + .entries + .into_iter() + .map( + |entry| codex_protocol::memory_citation::MemoryCitationEntry { + path: entry.path, + line_start: entry.line_start, + line_end: entry.line_end, + note: entry.note, + }, + ) + .collect(), + rollout_ids: citation.thread_ids, + } + }), + })), + ThreadItem::Plan { id, text } => Some(TurnItem::Plan(PlanItem { + id: id.clone(), + text: text.clone(), + })), + ThreadItem::Reasoning { + id, + summary, + content, + } => Some(TurnItem::Reasoning(ReasoningItem { + id: id.clone(), + summary_text: summary.clone(), + raw_content: content.clone(), + })), + ThreadItem::WebSearch { id, query, action } => Some(TurnItem::WebSearch(WebSearchItem { + id: id.clone(), + query: query.clone(), + action: app_server_web_search_action_to_core(action.clone()?)?, + })), + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + result, + } => Some(TurnItem::ImageGeneration(ImageGenerationItem { + id: id.clone(), + status: status.clone(), + revised_prompt: revised_prompt.clone(), + result: result.clone(), + saved_path: None, + })), + ThreadItem::ContextCompaction { id } => { + Some(TurnItem::ContextCompaction(ContextCompactionItem { + id: id.clone(), + })) + } + ThreadItem::CommandExecution { .. } + | ThreadItem::FileChange { .. } + | ThreadItem::McpToolCall { .. } + | ThreadItem::DynamicToolCall { .. } + | ThreadItem::CollabAgentToolCall { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } => { + tracing::debug!("ignoring unsupported app-server thread item in TUI adapter"); + None + } + } +} + +#[cfg(test)] +fn command_execution_started_event(turn_id: &str, item: &ThreadItem) -> Option> { + let ThreadItem::CommandExecution { + id, + command, + cwd, + process_id, + source, + command_actions, + .. + } = item + else { + return None; + }; + + Some(vec![Event { + id: String::new(), + msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: id.clone(), + process_id: process_id.clone(), + turn_id: turn_id.to_string(), + command: split_command_string(command), + cwd: cwd.clone(), + parsed_cmd: command_actions + .iter() + .cloned() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + source: source.to_core(), + interaction_input: None, + }), + }]) +} + +#[cfg(test)] +fn command_execution_completed_event(turn_id: &str, item: &ThreadItem) -> Option> { + let ThreadItem::CommandExecution { + id, + command, + cwd, + process_id, + source, + status, + command_actions, + aggregated_output, + exit_code, + duration_ms, + } = item + else { + return None; + }; + + if matches!( + status, + codex_app_server_protocol::CommandExecutionStatus::InProgress + ) { + return Some(Vec::new()); + } + + let status = match status { + codex_app_server_protocol::CommandExecutionStatus::InProgress => return Some(Vec::new()), + codex_app_server_protocol::CommandExecutionStatus::Completed => { + ExecCommandStatus::Completed + } + codex_app_server_protocol::CommandExecutionStatus::Failed => ExecCommandStatus::Failed, + codex_app_server_protocol::CommandExecutionStatus::Declined => ExecCommandStatus::Declined, + }; + + let duration = Duration::from_millis( + duration_ms + .and_then(|value| u64::try_from(value).ok()) + .unwrap_or_default(), + ); + let aggregated_output = aggregated_output.clone().unwrap_or_default(); + + Some(vec![Event { + id: String::new(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: id.clone(), + process_id: process_id.clone(), + turn_id: turn_id.to_string(), + command: split_command_string(command), + cwd: cwd.clone(), + parsed_cmd: command_actions + .iter() + .cloned() + .map(codex_app_server_protocol::CommandAction::into_core) + .collect(), + source: source.to_core(), + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: aggregated_output.clone(), + exit_code: exit_code.unwrap_or(-1), + duration, + formatted_output: aggregated_output, + status, + }), + }]) +} + +#[cfg(test)] +fn command_execution_snapshot_events(turn_id: &str, item: &ThreadItem) -> Option> { + let mut events = command_execution_started_event(turn_id, item)?; + if let Some(end_events) = command_execution_completed_event(turn_id, item) { + events.extend(end_events); + } + Some(events) +} + +#[cfg(test)] +fn split_command_string(command: &str) -> Vec { + let Some(parts) = shlex::split(command) else { + return vec![command.to_string()]; + }; + match shlex::try_join(parts.iter().map(String::as_str)) { + Ok(round_trip) + if round_trip == command + || (!command.contains(":\\") + && shlex::split(&round_trip).as_ref() == Some(&parts)) => + { + parts + } + _ => vec![command.to_string()], + } +} + +#[cfg(test)] +mod refresh_tests { + use super::*; + + use base64::Engine; + use chrono::Utc; + use codex_app_server_protocol::AuthMode; + use codex_core::auth::AuthCredentialsStoreMode; + use codex_core::auth::AuthDotJson; + use codex_core::auth::save_auth; + use codex_core::token_data::TokenData; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::json; + use tempfile::TempDir; + + fn fake_jwt(account_id: &str, plan_type: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": "user@example.com", + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + "chatgpt_plan_type": plan_type, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") + } + + fn write_chatgpt_auth(codex_home: &std::path::Path) { + let id_token = fake_jwt("workspace-1", "business"); + let access_token = fake_jwt("workspace-1", "business"); + save_auth( + codex_home, + &AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some("workspace-1".to_string()), + }), + last_refresh: Some(Utc::now()), + }, + AuthCredentialsStoreMode::File, + ) + .expect("chatgpt auth should save"); + } + + #[test] + fn refresh_request_uses_local_chatgpt_auth() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let response = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-1".to_string()), + }, + ) + .expect("refresh response should resolve"); + + assert_eq!(response.chatgpt_account_id, "workspace-1"); + assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business")); + assert!(!response.access_token.is_empty()); + } + + #[test] + fn refresh_request_rejects_account_mismatch() { + let codex_home = TempDir::new().expect("tempdir"); + write_chatgpt_auth(codex_home.path()); + + let err = resolve_chatgpt_auth_tokens_refresh_response( + codex_home.path(), + AuthCredentialsStoreMode::File, + Some("workspace-1"), + &ChatgptAuthTokensRefreshParams { + reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized, + previous_account_id: Some("workspace-2".to_string()), + }, + ) + .expect_err("mismatched account should fail"); + + assert_eq!( + err, + "local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`" + ); + } +} + +#[cfg(test)] +fn app_server_web_search_action_to_core( + action: codex_app_server_protocol::WebSearchAction, +) -> Option { + match action { + codex_app_server_protocol::WebSearchAction::Search { query, queries } => { + Some(codex_protocol::models::WebSearchAction::Search { query, queries }) + } + codex_app_server_protocol::WebSearchAction::OpenPage { url } => { + Some(codex_protocol::models::WebSearchAction::OpenPage { url }) + } + codex_app_server_protocol::WebSearchAction::FindInPage { url, pattern } => { + Some(codex_protocol::models::WebSearchAction::FindInPage { url, pattern }) + } + codex_app_server_protocol::WebSearchAction::Other => { + Some(codex_protocol::models::WebSearchAction::Other) + } + } +} + +#[cfg(test)] +fn app_server_codex_error_info_to_core( + value: codex_app_server_protocol::CodexErrorInfo, +) -> Option { + serde_json::from_value(serde_json::to_value(value).ok()?).ok() +} + #[cfg(test)] mod tests { use super::LegacyThreadNotification; - use super::ServerNotificationThreadTarget; + use super::command_execution_started_event; use super::legacy_thread_notification; - use super::server_notification_thread_target; + use super::server_notification_thread_events; + use super::thread_snapshot_events; + use super::turn_snapshot_events; + use codex_app_server_protocol::AgentMessageDeltaNotification; + use codex_app_server_protocol::CodexErrorInfo; + use codex_app_server_protocol::CommandAction; + use codex_app_server_protocol::CommandExecutionOutputDeltaNotification; + use codex_app_server_protocol::CommandExecutionSource; + use codex_app_server_protocol::CommandExecutionStatus; + use codex_app_server_protocol::ItemCompletedNotification; + use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCNotification; + use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; - use codex_app_server_protocol::TurnStartedNotification; + use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnStatus; use codex_protocol::ThreadId; + use codex_protocol::items::AgentMessageContent; + use codex_protocol::items::AgentMessageItem; + use codex_protocol::items::TurnItem; + use codex_protocol::models::MessagePhase; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ExecCommandSource; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::TurnAbortedEvent; use pretty_assertions::assert_eq; use serde_json::json; + use std::path::PathBuf; #[test] fn legacy_warning_notification_extracts_thread_id_and_message() { @@ -523,22 +1344,6 @@ mod tests { ); } - #[test] - fn legacy_warning_notification_ignores_non_warning_legacy_events() { - let notification = legacy_thread_notification(JSONRPCNotification { - method: "codex/event/task_started".to_string(), - params: Some(json!({ - "conversationId": ThreadId::new().to_string(), - "id": "event-1", - "msg": { - "type": "task_started", - }, - })), - }); - - assert_eq!(notification, None); - } - #[test] fn legacy_thread_rollback_notification_extracts_thread_id_and_turn_count() { let thread_id = ThreadId::new(); @@ -564,20 +1369,548 @@ mod tests { } #[test] - fn thread_scoped_notification_with_invalid_thread_id_is_not_treated_as_global() { - let notification = ServerNotification::TurnStarted(TurnStartedNotification { - thread_id: "not-a-thread-id".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: Vec::new(), - status: TurnStatus::InProgress, - error: None, - }, - }); + fn bridges_completed_agent_messages_from_server_notifications() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); + let item_id = "msg_123".to_string(); + + let (actual_thread_id, events) = server_notification_thread_events( + ServerNotification::ItemCompleted(ItemCompletedNotification { + item: ThreadItem::AgentMessage { + id: item_id, + text: "Hello from your coding assistant.".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + }, + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }), + ) + .expect("notification should bridge"); assert_eq!( - server_notification_thread_target(¬ification), - ServerNotificationThreadTarget::InvalidThreadId("not-a-thread-id".to_string()) + actual_thread_id, + ThreadId::from_string(&thread_id).expect("valid thread id") + ); + let [event] = events.as_slice() else { + panic!("expected one bridged event"); + }; + assert_eq!(event.id, String::new()); + let EventMsg::ItemCompleted(completed) = &event.msg else { + panic!("expected item completed event"); + }; + assert_eq!( + completed.thread_id, + ThreadId::from_string(&thread_id).expect("valid thread id") + ); + assert_eq!(completed.turn_id, turn_id); + match &completed.item { + TurnItem::AgentMessage(AgentMessageItem { + id, content, phase, .. + }) => { + assert_eq!(id, "msg_123"); + let [AgentMessageContent::Text { text }] = content.as_slice() else { + panic!("expected a single text content item"); + }; + assert_eq!(text, "Hello from your coding assistant."); + assert_eq!(*phase, Some(MessagePhase::FinalAnswer)); + } + _ => panic!("expected bridged agent message item"), + } + } + + #[test] + fn bridges_turn_completion_from_server_notifications() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); + + let (actual_thread_id, events) = server_notification_thread_events( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.clone(), + turn: Turn { + id: turn_id.clone(), + items: Vec::new(), + status: TurnStatus::Completed, + error: None, + }, + }), + ) + .expect("notification should bridge"); + + assert_eq!( + actual_thread_id, + ThreadId::from_string(&thread_id).expect("valid thread id") + ); + let [event] = events.as_slice() else { + panic!("expected one bridged event"); + }; + assert_eq!(event.id, String::new()); + let EventMsg::TurnComplete(completed) = &event.msg else { + panic!("expected turn complete event"); + }; + assert_eq!(completed.turn_id, turn_id); + assert_eq!(completed.last_agent_message, None); + } + + #[test] + fn bridges_command_execution_notifications_into_legacy_exec_events() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); + let item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "printf 'hello world\\n'".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::UserShell, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "printf hello world".to_string(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + + let (_, started_events) = server_notification_thread_events( + ServerNotification::ItemStarted(ItemStartedNotification { + item, + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + }), + ) + .expect("command execution start should bridge"); + let [started] = started_events.as_slice() else { + panic!("expected one started event"); + }; + let EventMsg::ExecCommandBegin(begin) = &started.msg else { + panic!("expected exec begin event"); + }; + assert_eq!(begin.call_id, "cmd-1"); + assert_eq!( + begin.command, + vec!["printf".to_string(), "hello world\\n".to_string()] + ); + assert_eq!(begin.cwd, PathBuf::from("/tmp")); + assert_eq!(begin.source, ExecCommandSource::UserShell); + + let (_, delta_events) = + server_notification_thread_events(ServerNotification::CommandExecutionOutputDelta( + CommandExecutionOutputDeltaNotification { + thread_id: thread_id.clone(), + turn_id: turn_id.clone(), + item_id: "cmd-1".to_string(), + delta: "hello world\n".to_string(), + }, + )) + .expect("command execution delta should bridge"); + let [delta] = delta_events.as_slice() else { + panic!("expected one delta event"); + }; + let EventMsg::ExecCommandOutputDelta(delta) = &delta.msg else { + panic!("expected exec output delta event"); + }; + assert_eq!(delta.call_id, "cmd-1"); + assert_eq!(delta.chunk, b"hello world\n"); + + let completed_item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "printf 'hello world\\n'".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::UserShell, + status: CommandExecutionStatus::Completed, + command_actions: vec![CommandAction::Unknown { + command: "printf hello world".to_string(), + }], + aggregated_output: Some("hello world\n".to_string()), + exit_code: Some(0), + duration_ms: Some(5), + }; + let (_, completed_events) = server_notification_thread_events( + ServerNotification::ItemCompleted(ItemCompletedNotification { + item: completed_item, + thread_id, + turn_id, + }), + ) + .expect("command execution completion should bridge"); + let [completed] = completed_events.as_slice() else { + panic!("expected one completed event"); + }; + let EventMsg::ExecCommandEnd(end) = &completed.msg else { + panic!("expected exec end event"); + }; + assert_eq!(end.call_id, "cmd-1"); + assert_eq!(end.exit_code, 0); + assert_eq!(end.formatted_output, "hello world\n"); + assert_eq!(end.aggregated_output, "hello world\n"); + assert_eq!(end.source, ExecCommandSource::UserShell); + } + + #[test] + fn command_execution_snapshot_preserves_non_roundtrippable_command_strings() { + let item = ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_string(), + cwd: PathBuf::from("C:\\repo"), + process_id: None, + source: CommandExecutionSource::UserShell, + status: CommandExecutionStatus::InProgress, + command_actions: vec![], + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + + let events = + command_execution_started_event("turn-1", &item).expect("command execution start"); + let [started] = events.as_slice() else { + panic!("expected one started event"); + }; + let EventMsg::ExecCommandBegin(begin) = &started.msg else { + panic!("expected exec begin event"); + }; + assert_eq!( + begin.command, + vec![r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_string()] ); } + + #[test] + fn replays_command_execution_items_from_thread_snapshots() { + let thread = Thread { + id: "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 1, + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "test".to_string(), + source: SessionSource::Cli.into(), + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![Turn { + id: "turn-1".to_string(), + items: vec![ThreadItem::CommandExecution { + id: "cmd-1".to_string(), + command: "printf 'hello world\\n'".to_string(), + cwd: PathBuf::from("/tmp"), + process_id: None, + source: CommandExecutionSource::UserShell, + status: CommandExecutionStatus::Completed, + command_actions: vec![CommandAction::Unknown { + command: "printf hello world".to_string(), + }], + aggregated_output: Some("hello world\n".to_string()), + exit_code: Some(0), + duration_ms: Some(5), + }], + status: TurnStatus::Completed, + error: None, + }], + }; + + let events = thread_snapshot_events(&thread, /*show_raw_agent_reasoning*/ false); + assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); + let EventMsg::ExecCommandBegin(begin) = &events[1].msg else { + panic!("expected exec begin event"); + }; + assert_eq!(begin.call_id, "cmd-1"); + assert_eq!(begin.source, ExecCommandSource::UserShell); + let EventMsg::ExecCommandEnd(end) = &events[2].msg else { + panic!("expected exec end event"); + }; + assert_eq!(end.call_id, "cmd-1"); + assert_eq!(end.formatted_output, "hello world\n"); + assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); + } + + #[test] + fn bridges_interrupted_turn_completion_from_server_notifications() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); + + let (actual_thread_id, events) = server_notification_thread_events( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.clone(), + turn: Turn { + id: turn_id.clone(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + }), + ) + .expect("notification should bridge"); + + assert_eq!( + actual_thread_id, + ThreadId::from_string(&thread_id).expect("valid thread id") + ); + let [event] = events.as_slice() else { + panic!("expected one bridged event"); + }; + let EventMsg::TurnAborted(aborted) = &event.msg else { + panic!("expected turn aborted event"); + }; + assert_eq!(aborted.turn_id.as_deref(), Some(turn_id.as_str())); + assert_eq!(aborted.reason, TurnAbortReason::Interrupted); + } + + #[test] + fn bridges_failed_turn_completion_from_server_notifications() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + let turn_id = "019cee8c-b9b4-7f10-a1b0-38caa876a012".to_string(); + + let (actual_thread_id, events) = server_notification_thread_events( + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: thread_id.clone(), + turn: Turn { + id: turn_id.clone(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(TurnError { + message: "request failed".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, + }), + }, + }), + ) + .expect("notification should bridge"); + + assert_eq!( + actual_thread_id, + ThreadId::from_string(&thread_id).expect("valid thread id") + ); + let [complete_event] = events.as_slice() else { + panic!("expected turn completion only"); + }; + let EventMsg::TurnComplete(completed) = &complete_event.msg else { + panic!("expected turn complete event"); + }; + assert_eq!(completed.turn_id, turn_id); + assert_eq!(completed.last_agent_message, None); + } + + #[test] + fn bridges_text_deltas_from_server_notifications() { + let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string(); + + let (_, agent_events) = server_notification_thread_events( + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: thread_id.clone(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "Hello".to_string(), + }), + ) + .expect("notification should bridge"); + let [agent_event] = agent_events.as_slice() else { + panic!("expected one bridged agent delta event"); + }; + assert_eq!(agent_event.id, String::new()); + let EventMsg::AgentMessageDelta(delta) = &agent_event.msg else { + panic!("expected bridged agent message delta"); + }; + assert_eq!(delta.delta, "Hello"); + + let (_, reasoning_events) = server_notification_thread_events( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id, + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "Thinking".to_string(), + summary_index: 0, + }), + ) + .expect("notification should bridge"); + let [reasoning_event] = reasoning_events.as_slice() else { + panic!("expected one bridged reasoning delta event"); + }; + assert_eq!(reasoning_event.id, String::new()); + let EventMsg::AgentReasoningDelta(delta) = &reasoning_event.msg else { + panic!("expected bridged reasoning delta"); + }; + assert_eq!(delta.delta, "Thinking"); + } + + #[test] + fn bridges_thread_snapshot_turns_for_resume_restore() { + let thread_id = ThreadId::new(); + let events = thread_snapshot_events( + &Thread { + id: thread_id.to_string(), + preview: "hello".to_string(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp/project"), + cli_version: "test".to_string(), + source: SessionSource::Cli.into(), + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some("restore".to_string()), + turns: vec![ + Turn { + id: "turn-complete".to_string(), + items: vec![ + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![codex_app_server_protocol::UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + }, + ThreadItem::AgentMessage { + id: "assistant-1".to_string(), + text: "hi".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: None, + }, + ], + status: TurnStatus::Completed, + error: None, + }, + Turn { + id: "turn-interrupted".to_string(), + items: Vec::new(), + status: TurnStatus::Interrupted, + error: None, + }, + Turn { + id: "turn-failed".to_string(), + items: Vec::new(), + status: TurnStatus::Failed, + error: Some(TurnError { + message: "request failed".to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, + }), + }, + ], + }, + /*show_raw_agent_reasoning*/ false, + ); + + assert_eq!(events.len(), 9); + assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); + assert!(matches!(events[1].msg, EventMsg::ItemCompleted(_))); + assert!(matches!(events[2].msg, EventMsg::ItemCompleted(_))); + assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); + assert!(matches!(events[4].msg, EventMsg::TurnStarted(_))); + let EventMsg::TurnAborted(TurnAbortedEvent { turn_id, reason }) = &events[5].msg else { + panic!("expected interrupted turn replay"); + }; + assert_eq!(turn_id.as_deref(), Some("turn-interrupted")); + assert_eq!(*reason, TurnAbortReason::Interrupted); + assert!(matches!(events[6].msg, EventMsg::TurnStarted(_))); + let EventMsg::Error(error) = &events[7].msg else { + panic!("expected failed turn error replay"); + }; + assert_eq!(error.message, "request failed"); + assert_eq!( + error.codex_error_info, + Some(codex_protocol::protocol::CodexErrorInfo::Other) + ); + assert!(matches!(events[8].msg, EventMsg::TurnComplete(_))); + } + + #[test] + fn bridges_non_message_snapshot_items_via_legacy_events() { + let events = turn_snapshot_events( + ThreadId::new(), + &Turn { + id: "turn-complete".to_string(), + items: vec![ + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["Need to inspect config".to_string()], + content: vec!["hidden chain".to_string()], + }, + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "ratatui stylize".to_string(), + action: Some(codex_app_server_protocol::WebSearchAction::Other), + }, + ThreadItem::ImageGeneration { + id: "image-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("diagram".to_string()), + result: "image.png".to_string(), + }, + ThreadItem::ContextCompaction { + id: "compact-1".to_string(), + }, + ], + status: TurnStatus::Completed, + error: None, + }, + /*show_raw_agent_reasoning*/ false, + ); + + assert_eq!(events.len(), 6); + assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); + let EventMsg::AgentReasoning(reasoning) = &events[1].msg else { + panic!("expected reasoning replay"); + }; + assert_eq!(reasoning.text, "Need to inspect config"); + let EventMsg::WebSearchEnd(web_search) = &events[2].msg else { + panic!("expected web search replay"); + }; + assert_eq!(web_search.call_id, "search-1"); + assert_eq!(web_search.query, "ratatui stylize"); + assert_eq!( + web_search.action, + codex_protocol::models::WebSearchAction::Other + ); + let EventMsg::ImageGenerationEnd(image_generation) = &events[3].msg else { + panic!("expected image generation replay"); + }; + assert_eq!(image_generation.call_id, "image-1"); + assert_eq!(image_generation.status, "completed"); + assert_eq!(image_generation.revised_prompt.as_deref(), Some("diagram")); + assert_eq!(image_generation.result, "image.png"); + assert!(matches!(events[4].msg, EventMsg::ContextCompacted(_))); + assert!(matches!(events[5].msg, EventMsg::TurnComplete(_))); + } + + #[test] + fn bridges_raw_reasoning_snapshot_items_when_enabled() { + let events = turn_snapshot_events( + ThreadId::new(), + &Turn { + id: "turn-complete".to_string(), + items: vec![ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["Need to inspect config".to_string()], + content: vec!["hidden chain".to_string()], + }], + status: TurnStatus::Completed, + error: None, + }, + /*show_raw_agent_reasoning*/ true, + ); + + assert_eq!(events.len(), 4); + assert!(matches!(events[0].msg, EventMsg::TurnStarted(_))); + let EventMsg::AgentReasoning(reasoning) = &events[1].msg else { + panic!("expected reasoning replay"); + }; + assert_eq!(reasoning.text, "Need to inspect config"); + let EventMsg::AgentReasoningRawContent(raw_reasoning) = &events[2].msg else { + panic!("expected raw reasoning replay"); + }; + assert_eq!(raw_reasoning.text, "hidden chain"); + assert!(matches!(events[3].msg, EventMsg::TurnComplete(_))); + } } diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index 336f305aa..ed89ad86f 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -35,6 +35,9 @@ pub(crate) enum AppCommandView<'a> { RealtimeConversationAudio(&'a ConversationAudioParams), RealtimeConversationText(&'a ConversationTextParams), RealtimeConversationClose, + RunUserShellCommand { + command: &'a str, + }, UserTurn { items: &'a [UserInput], cwd: &'a PathBuf, @@ -134,6 +137,10 @@ impl AppCommand { Self(Op::RealtimeConversationClose) } + pub(crate) fn run_user_shell_command(command: String) -> Self { + Self(Op::RunUserShellCommand { command }) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn user_turn( items: Vec, @@ -291,6 +298,7 @@ impl AppCommand { AppCommandView::RealtimeConversationText(params) } Op::RealtimeConversationClose => AppCommandView::RealtimeConversationClose, + Op::RunUserShellCommand { command } => AppCommandView::RunUserShellCommand { command }, Op::UserTurn { items, cwd, diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index 6a8efa825..c8a24acff 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -42,6 +42,8 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -492,6 +494,26 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_shell_command( + &mut self, + thread_id: ThreadId, + command: String, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadShellCommandResponse = self + .client + .request_typed(ClientRequest::ThreadShellCommand { + request_id, + params: ThreadShellCommandParams { + thread_id: thread_id.to_string(), + command, + }, + }) + .await + .wrap_err("thread/shellCommand failed in app-server TUI")?; + Ok(()) + } + pub(crate) async fn thread_background_terminals_clean( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 80d3177cf..f91ebbaea 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5134,13 +5134,7 @@ impl ChatWidget { ))); return; } - // TODO: Restore `!` support in app-server TUI once command execution can - // persist transcript-visible output into thread history with parity to the - // legacy TUI. - self.add_to_history(history_cell::new_error_event( - "`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history.".to_string(), - )); - self.request_redraw(); + self.submit_op(AppCommand::run_user_shell_command(cmd.to_string())); return; } @@ -5562,6 +5556,7 @@ impl ChatWidget { command, cwd, process_id, + source, status, command_actions, aggregated_output, @@ -5582,10 +5577,11 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: ExecCommandSource::Agent, + source: source.to_core(), interaction_input: None, }); } else { + let aggregated_output = aggregated_output.unwrap_or_default(); self.on_exec_command_end(ExecCommandEndEvent { call_id: id, process_id, @@ -5596,16 +5592,16 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: ExecCommandSource::Agent, + source: source.to_core(), interaction_input: None, stdout: String::new(), stderr: String::new(), - aggregated_output: aggregated_output.unwrap_or_default(), + aggregated_output: aggregated_output.clone(), exit_code: exit_code.unwrap_or_default(), duration: Duration::from_millis( duration_ms.unwrap_or_default().max(0) as u64 ), - formatted_output: String::new(), + formatted_output: aggregated_output, status: match status { codex_app_server_protocol::CommandExecutionStatus::Completed => { codex_protocol::protocol::ExecCommandStatus::Completed @@ -6144,6 +6140,7 @@ impl ChatWidget { command, cwd, process_id, + source, command_actions, .. } => { @@ -6157,7 +6154,7 @@ impl ChatWidget { .into_iter() .map(codex_app_server_protocol::CommandAction::into_core) .collect(), - source: ExecCommandSource::Agent, + source: source.to_core(), interaction_input: None, }); } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 39baea655..bae556d51 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -8840,7 +8840,7 @@ async fn user_shell_command_renders_output_not_exploring() { } #[tokio::test] -async fn bang_shell_command_is_disabled_in_app_server_tui() { +async fn bang_shell_command_submits_run_user_shell_command_in_app_server_tui() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; let conversation_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); @@ -8873,22 +8873,11 @@ async fn bang_shell_command_is_disabled_in_app_server_tui() { .set_composer_text("!echo hi".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - - let mut rendered = None; - while let Ok(event) = rx.try_recv() { - if let AppEvent::InsertHistoryCell(cell) = event { - rendered = Some(lines_to_single_string(&cell.display_lines(80))); - break; - } + match op_rx.try_recv() { + Ok(Op::RunUserShellCommand { command }) => assert_eq!(command, "echo hi"), + other => panic!("expected RunUserShellCommand op, got {other:?}"), } - let rendered = rendered.expect("expected disabled bang-shell error"); - assert!( - rendered.contains( - "`!` shell commands are unavailable in app-server TUI because command output is not yet persisted in thread history." - ), - "expected bang-shell disabled message, got: {rendered}" - ); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); } #[tokio::test]