diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index c7f5b9889a3b..7c311707790c 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -176,6 +176,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific matches!( notification, ServerNotification::TurnCompleted(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ItemCompleted(_) | ServerNotification::AgentMessageDelta(_) | ServerNotification::PlanDelta(_) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b9058aa1ceba..2a36d66c1d8e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -64,6 +64,26 @@ }, "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -415,6 +435,65 @@ ], "type": "object" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -658,6 +737,22 @@ ], "type": "string" }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, "CommandAction": { "oneOf": [ { @@ -2258,6 +2353,14 @@ } ] }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, "ModelRerouteReason": { "enum": [ "highRiskCyberActivity" @@ -2319,6 +2422,13 @@ ], "type": "object" }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -2402,6 +2512,14 @@ } ] }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, "PlanDeltaNotification": { "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { @@ -2655,6 +2773,26 @@ ], "type": "string" }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, "ReasoningSummaryPartAddedNotification": { "properties": { "itemId": { @@ -2807,6 +2945,105 @@ }, "type": "object" }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, "ServerRequestResolvedNotification": { "properties": { "requestId": { @@ -2862,6 +3099,34 @@ } ] }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, "SkillsChangedNotification": { "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" @@ -4148,6 +4413,102 @@ ], "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "type": "object" + }, "ThreadSource": { "enum": [ "user", @@ -5089,6 +5450,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { 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 b50cacefa487..7eab6a3b2832 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 @@ -4031,6 +4031,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -17117,6 +17137,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/v2/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/v2/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/v2/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9da530463a19..2e57b52d6dc2 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 @@ -11290,6 +11290,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -14941,6 +14961,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json new file mode 100644 index 000000000000..d016e2831141 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -0,0 +1,381 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f4dd0e1864c2..3ed710efc0a3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -54,6 +54,7 @@ import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotifi import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification"; import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification"; +import type { ThreadSettingsUpdatedNotification } from "./v2/ThreadSettingsUpdatedNotification"; import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; @@ -69,4 +70,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts new file mode 100644 index 000000000000..bcfd0ad86ce3 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { CollaborationMode } from "../CollaborationMode"; +import type { Personality } from "../Personality"; +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts new file mode 100644 index 000000000000..964811ca69cd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadSettings } from "./ThreadSettings"; + +export type ThreadSettingsUpdatedNotification = { threadId: string, threadSettings: ThreadSettings, }; 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 7c09be0def07..d03d9197ef97 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -394,6 +394,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadSettings } from "./ThreadSettings"; +export type { ThreadSettingsUpdatedNotification } from "./ThreadSettingsUpdatedNotification"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7a7fe5642c99..38256adf98c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -517,6 +517,13 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadMetadataUpdateResponse, }, + #[experimental("thread/settings/update")] + ThreadSettingsUpdate => "thread/settings/update" { + params: v2::ThreadSettingsUpdateParams, + inspect_params: true, + serialization: thread_id(params.thread_id), + response: v2::ThreadSettingsUpdateResponse, + }, #[experimental("thread/memoryMode/set")] ThreadMemoryModeSet => "thread/memoryMode/set" { params: v2::ThreadMemoryModeSetParams, @@ -1465,6 +1472,8 @@ server_notification_definitions! { ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), #[experimental("thread/goal/cleared")] ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), + #[experimental("thread/settings/updated")] + ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -3089,6 +3098,40 @@ mod tests { ); } + #[test] + fn thread_settings_updated_notification_is_marked_experimental() { + let notification = + ServerNotification::ThreadSettingsUpdated(v2::ThreadSettingsUpdatedNotification { + thread_id: "thr_123".to_string(), + thread_settings: v2::ThreadSettings { + cwd: absolute_path("/tmp/repo"), + approval_policy: v2::AskForApproval::Never, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox_policy: v2::SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + }, + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("thread/settings/updated") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2ee583f991b1..4b5745e5c351 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3578,6 +3578,77 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn thread_settings_update_params_preserve_explicit_null_service_tier() { + let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + service_tier: None, + ..Default::default() + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_settings_update_params_preserve_field_level_experimental_gates() { + let permissions = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + permissions: Some(":workspace".to_string()), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&permissions), + Some("thread/settings/update.permissions") + ); + + let granular_approval = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&granular_approval), + Some("askForApproval.granular") + ); + + let collaboration_mode = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: "mock-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&collaboration_mode), + Some("thread/settings/update.collaborationMode") + ); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 059bef05f272..35dbca288aed 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -11,7 +11,9 @@ use super::TurnEnvironmentParams; use super::TurnItemsView; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -219,6 +221,93 @@ pub struct ThreadStartResponse { pub reasoning_effort: Option, } +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateParams { + pub thread_id: String, + /// Override the working directory for subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile id for subsequent turns. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("thread/settings/update.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for subsequent turns. `null` clears the + /// current service tier; omission leaves it unchanged. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("thread/settings/update.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, + /// Override the personality for subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettings { + pub cwd: AbsolutePathBuf, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + pub active_permission_profile: Option, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub effort: Option, + pub summary: Option, + pub collaboration_mode: CollaborationMode, + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdatedNotification { + pub thread_id: String, + pub thread_settings: ThreadSettings, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1b7575141f36..3a944782d89e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -140,6 +140,7 @@ Example with notification opt-out: - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -147,6 +148,7 @@ Example with notification opt-out: - `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. - `thread/goal/cleared` — notification emitted whenever a thread goal is removed. +- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 5b137c109135..efe6c69a32e9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4,6 +4,7 @@ use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::request_processors::populate_thread_turns_from_history; use crate::request_processors::thread_from_stored_thread; +use crate::request_processors::thread_settings_from_core_snapshot; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; @@ -60,6 +61,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -1200,6 +1202,24 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; } + EventMsg::ThreadSettingsApplied(thread_settings_event) => { + let thread_settings = + thread_settings_from_core_snapshot(thread_settings_event.thread_settings); + let changed = { + let mut state = thread_state.lock().await; + state.note_thread_settings(thread_settings.clone()) + }; + if changed { + outgoing + .send_server_notification(ServerNotification::ThreadSettingsUpdated( + ThreadSettingsUpdatedNotification { + thread_id: conversation_id.to_string(), + thread_settings, + }, + )) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await; } diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c75c2d5ad10f..f7072d0fa17d 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -102,7 +102,10 @@ pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; type PendingClientRequestResponse = std::result::Result; fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { - matches!(notification, ServerNotification::TurnCompleted(_)) + matches!( + notification, + ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_) + ) } /// Input needed to start an in-process app-server runtime. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e37c9afd224e..727810b36c35 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1033,6 +1033,11 @@ impl MessageProcessor { ClientRequest::ThreadMetadataUpdate { params, .. } => { self.thread_processor.thread_metadata_update(params).await } + ClientRequest::ThreadSettingsUpdate { params, .. } => { + self.turn_processor + .thread_settings_update(&request_id, params) + .await + } ClientRequest::ThreadMemoryModeSet { params, .. } => { self.thread_processor.thread_memory_mode_set(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 3e84c397f833..fdbd587255f3 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -213,6 +213,9 @@ 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::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; @@ -350,6 +353,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; @@ -358,6 +362,7 @@ use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; #[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; @@ -510,6 +515,8 @@ pub(crate) use self::thread_processor::thread_from_stored_thread; pub(crate) use self::thread_summary::read_summary_from_rollout; #[cfg(test)] pub(crate) use self::thread_summary::summary_to_thread; +pub(crate) use self::thread_summary::thread_settings_from_config_snapshot; +pub(crate) use self::thread_summary::thread_settings_from_core_snapshot; pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { let mut builder = ThreadHistoryBuilder::new(); diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index c4d5fa6c96a4..985baac91adb 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -237,12 +237,19 @@ pub(super) async fn ensure_listener_task_running( &environments, ) .await; + let thread_settings_baseline = + thread_settings_from_config_snapshot(&conversation.config_snapshot().await); let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener(cancel_tx, &conversation, watch_registration) + thread_state.set_listener( + cancel_tx, + &conversation, + watch_registration, + thread_settings_baseline, + ) }; let ListenerTaskContext { outgoing, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index fea49b163791..0a6c1bfb0fa4 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -169,13 +169,13 @@ pub(super) fn with_thread_spawn_agent_metadata( } } -pub(super) fn thread_response_active_permission_profile( +pub(crate) fn thread_response_active_permission_profile( active_permission_profile: Option, ) -> Option { active_permission_profile.map(Into::into) } -pub(super) fn thread_response_sandbox_policy( +pub(crate) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { @@ -189,6 +189,54 @@ pub(super) fn thread_response_sandbox_policy( sandbox_policy.into() } +pub(crate) fn thread_settings_from_config_snapshot( + config_snapshot: &ThreadConfigSnapshot, +) -> ThreadSettings { + ThreadSettings { + cwd: config_snapshot.cwd.clone(), + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox_policy: thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ), + active_permission_profile: thread_response_active_permission_profile( + config_snapshot.active_permission_profile.clone(), + ), + model: config_snapshot.model.clone(), + model_provider: config_snapshot.model_provider_id.clone(), + service_tier: config_snapshot.service_tier.clone(), + effort: config_snapshot.reasoning_effort, + summary: config_snapshot.reasoning_summary, + collaboration_mode: config_snapshot.collaboration_mode.clone(), + personality: config_snapshot.personality, + } +} + +pub(crate) fn thread_settings_from_core_snapshot( + snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, +) -> ThreadSettings { + ThreadSettings { + sandbox_policy: thread_response_sandbox_policy( + &snapshot.permission_profile, + snapshot.cwd.as_path(), + ), + cwd: snapshot.cwd, + approval_policy: snapshot.approval_policy.into(), + approvals_reviewer: snapshot.approvals_reviewer.into(), + active_permission_profile: thread_response_active_permission_profile( + snapshot.active_permission_profile, + ), + model: snapshot.model, + model_provider: snapshot.model_provider_id, + service_tier: snapshot.service_tier, + effort: snapshot.reasoning_effort, + summary: snapshot.reasoning_summary, + collaboration_mode: snapshot.collaboration_mode, + personality: snapshot.personality, + } +} + #[cfg(test)] fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 71715e5079b5..ff2825be6780 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -30,6 +30,22 @@ fn resolve_runtime_workspace_roots( resolved_roots } +struct ThreadSettingsBuildParams { + method: &'static str, + cwd: Option, + runtime_workspace_roots: Option>, + approval_policy: Option, + approvals_reviewer: Option, + sandbox_policy: Option, + permissions: Option, + model: Option, + service_tier: Option>, + effort: Option, + summary: Option, + collaboration_mode: Option, + personality: Option, +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -88,6 +104,16 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_settings_update( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_settings_update_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, @@ -199,7 +225,7 @@ impl TurnRequestProcessor { Ok((thread_id, thread)) } - fn normalize_turn_start_collaboration_mode( + fn normalize_collaboration_mode( &self, mut collaboration_mode: CollaborationMode, ) -> CollaborationMode { @@ -357,9 +383,6 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let collaboration_mode = params - .collaboration_mode - .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); let environment_selections = self.parse_environment_selections(params.environments)?; // Map v2 input items to core input items. @@ -369,41 +392,132 @@ impl TurnRequestProcessor { .map(V2UserInput::into_core) .collect(); let turn_has_input = !mapped_items.is_empty(); - let runtime_workspace_roots_request = params.runtime_workspace_roots.clone(); - let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some() - { - Some(thread.config_snapshot().await) - } else { - None + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "turn/start", + cwd: params.cwd, + runtime_workspace_roots: params.runtime_workspace_roots, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + thread_settings, }; + let turn_id = self + .submit_core_op(&request_id, thread.as_ref(), turn_op) + .await + .map_err(|err| { + let error = internal_error(format!("failed to start turn: {err}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + error + })?; - let has_any_overrides = params.cwd.is_some() - || runtime_workspace_roots_request.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.permissions.is_some() - || params.model.is_some() - || params.service_tier.is_some() - || params.effort.is_some() - || params.summary.is_some() - || collaboration_mode.is_some() - || params.personality.is_some(); + if turn_has_input { + let config_snapshot = thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&thread), + thread.config().await, + &config_snapshot.session_source, + ); + } + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; + let turn = Turn { + id: turn_id, + items: vec![], + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + Ok(TurnStartResponse { turn }) + } - if params.sandbox_policy.is_some() && params.permissions.is_some() { + async fn build_thread_settings_overrides( + &self, + thread: &CodexThread, + params: ThreadSettingsBuildParams, + ) -> Result { + let ThreadSettingsBuildParams { + method, + cwd, + runtime_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permissions, + model, + service_tier, + effort, + summary, + collaboration_mode, + personality, + } = params; + + if sandbox_policy.is_some() && permissions.is_some() { return Err(invalid_request( "`permissions` cannot be combined with `sandboxPolicy`", )); } - let cwd = params.cwd; + let collaboration_mode = + collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); + let runtime_workspace_roots_request = runtime_workspace_roots; + // `thread/settings/update` only acknowledges that the update was queued. + // Clients that send dependent partial updates should wait for + // `thread/settings/updated` or combine the fields in one request. + let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + Some(thread.config_snapshot().await) + } else { + None + }; + + let has_any_overrides = cwd.is_some() + || runtime_workspace_roots_request.is_some() + || approval_policy.is_some() + || approvals_reviewer.is_some() + || sandbox_policy.is_some() + || permissions.is_some() + || model.is_some() + || service_tier.is_some() + || effort.is_some() + || summary.is_some() + || collaboration_mode.is_some() + || personality.is_some(); + let runtime_workspace_roots = if let Some(workspace_roots) = runtime_workspace_roots_request.clone() { let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start runtime workspace roots missing thread snapshot", - )); + return Err(internal_error(format!( + "{method} runtime workspace roots missing thread snapshot" + ))); }; let base_cwd = cwd .as_ref() @@ -413,17 +527,17 @@ impl TurnRequestProcessor { } else { None }; - let approval_policy = params.approval_policy.map(AskForApproval::to_core); - let approvals_reviewer = params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let approval_policy = + approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); + let approvals_reviewer = + approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = sandbox_policy.map(|policy| policy.to_core()); let (permission_profile, active_permission_profile, profile_workspace_roots) = - if let Some(permissions) = params.permissions { + if let Some(permissions) = permissions { let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start permission selection missing thread snapshot", - )); + return Err(internal_error(format!( + "{method} permission selection missing thread snapshot" + ))); }; let overrides = ConfigOverrides { cwd: cwd.clone(), @@ -451,8 +565,8 @@ impl TurnRequestProcessor { .await .map_err(|err| config_load_error(&err))?; // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. + // disallow a configured profile. An explicit settings update + // is different: reject it before accepting the request. if let Some(warning) = config.startup_warnings.iter().find(|warning| { warning.contains("Configured value for `permission_profile` is disallowed") }) { @@ -468,15 +582,8 @@ impl TurnRequestProcessor { } else { (None, None, None) }; - let model = params.model; - let effort = params.effort.map(Some); - let summary = params.summary; - let service_tier = params.service_tier; - let personality = params.personality; - - // If any overrides are provided, validate them synchronously so the - // request can fail before accepting user input. The actual update is - // still queued together with the input below to preserve submission order. + let effort = effort.map(Some); + if has_any_overrides { thread .preview_thread_settings_overrides(CodexThreadSettingsOverrides { @@ -502,7 +609,7 @@ impl TurnRequestProcessor { })?; } - let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides { + Ok(codex_protocol::protocol::ThreadSettingsOverrides { cwd, workspace_roots: runtime_workspace_roots, profile_workspace_roots, @@ -518,52 +625,47 @@ impl TurnRequestProcessor { service_tier, collaboration_mode, personality, - }; + }) + } - // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - thread_settings, - }; - let turn_id = self - .submit_core_op(&request_id, thread.as_ref(), turn_op) - .await - .map_err(|err| { - let error = internal_error(format!("failed to start turn: {err}")); - self.track_error_response(&request_id, &error, /*error_type*/ None); - error - })?; + async fn thread_settings_update_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "thread/settings/update", + cwd: params.cwd, + runtime_workspace_roots: None, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; - if turn_has_input { - let config_snapshot = thread.config_snapshot().await; - codex_memories_write::start_memories_startup_task( - Arc::clone(&self.thread_manager), - Arc::clone(&self.auth_manager), - thread_id, - Arc::clone(&thread), - thread.config().await, - &config_snapshot.session_source, - ); + if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() { + self.submit_core_op( + request_id, + thread.as_ref(), + Op::ThreadSettings { thread_settings }, + ) + .await + .map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?; } - self.outgoing - .record_request_turn_id(&request_id, &turn_id) - .await; - let turn = Turn { - id: turn_id, - items: vec![], - items_view: TurnItemsView::NotLoaded, - error: None, - status: TurnStatus::InProgress, - started_at: None, - completed_at: None, - duration_ms: None, - }; - - Ok(TurnStartResponse { turn }) + Ok(ThreadSettingsUpdateResponse {}) } async fn thread_inject_items_response_inner( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 32dfcc325d5f..7f864265b2d7 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -3,6 +3,7 @@ use crate::outgoing_message::ConnectionRequestId; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; @@ -76,6 +77,7 @@ pub(crate) struct ThreadState { pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, pub(crate) listener_generation: u64, + last_thread_settings: Option, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -95,11 +97,13 @@ impl ThreadState { cancel_tx: oneshot::Sender<()>, conversation: &Arc, watch_registration: WatchRegistration, + thread_settings_baseline: ThreadSettings, ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } self.listener_generation = self.listener_generation.wrapping_add(1); + self.last_thread_settings = Some(thread_settings_baseline); let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); @@ -143,6 +147,12 @@ impl ThreadState { self.current_turn_history.reset(); } } + + pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool { + let changed = self.last_thread_settings.as_ref() != Some(&thread_settings); + self.last_thread_settings = Some(thread_settings); + changed + } } pub(crate) async fn resolve_server_request_on_thread_listener( @@ -177,6 +187,60 @@ pub(crate) async fn resolve_server_request_on_thread_listener( } } +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ApprovalsReviewer; + use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::SandboxPolicy; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use pretty_assertions::assert_eq; + + #[test] + fn note_thread_settings_reports_only_effective_changes() { + let mut state = ThreadState::default(); + let initial = thread_settings("mock-model"); + let updated = thread_settings("mock-model-2"); + + let results = vec![ + state.note_thread_settings(initial.clone()), + state.note_thread_settings(initial), + state.note_thread_settings(updated.clone()), + state.note_thread_settings(updated), + ]; + + assert_eq!(results, vec![true, false, true, false]); + } + + fn thread_settings(model: &str) -> ThreadSettings { + ThreadSettings { + cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: model.to_string(), + model_provider: "mock_provider".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: model.to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + } + } +} + struct ThreadEntry { state: Arc>, connection_ids: HashSet, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3445010a7fca..3c66cf40b9ed 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -88,6 +88,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::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadTurnsItemsListParams; @@ -443,6 +444,15 @@ impl McpProcess { self.send_request("thread/metadata/update", params).await } + /// Send a `thread/settings/update` JSON-RPC request. + pub async fn send_thread_settings_update_request( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/settings/update", params).await + } + /// Send a `thread/unsubscribe` JSON-RPC request. pub async fn send_thread_unsubscribe_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 4096e3d96fd4..45b2e45e3e2e 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_protocol::protocol::RealtimeOutputModality; @@ -129,6 +130,40 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result Ok(()) } +#[tokio::test] +async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + request_attestation: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: "thr_123".to_string(), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/settings/update"); + Ok(()) +} + #[tokio::test] async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdbe7b7ddd56..4856e0b3a98b 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -57,6 +57,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_settings_update; mod thread_shell_command; mod thread_start; mod thread_status; diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs new file mode 100644 index 000000000000..1bc3d742742b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -0,0 +1,400 @@ +use anyhow::Context; +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_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use app_test_support::write_models_cache; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::test_support::all_model_presets; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + assert!( + received_response_bodies(&server).await?.is_empty(), + "settings-only update should not start a model request" + ); + + start_text_turn(&mut mcp, thread.id.clone()).await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, model_id); + assert_eq!( + updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read = read_thread_with_turns(&mut mcp, &thread.id).await?; + assert_eq!(read.thread.turns.len(), 1); + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body.get("service_tier").and_then(Value::as_str) + == Some(service_tier_id.as_str()) + }), + "future turn did not use updated model/service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = + responses::sse_response(create_final_assistant_message_sse_response("first done")?) + .set_delay(Duration::from_secs(2)); + let _requests = responses::mount_response_sequence(&server, vec![first_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + start_text_turn(&mut mcp, thread.id.clone()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some("mock-model-4".to_string()), + ..Default::default() + }, + ) + .await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-4"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_clears_service_tier() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + + let set_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(set_updated.thread_id, thread.id); + assert_eq!( + set_updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + service_tier: Some(None), + ..Default::default() + }, + ) + .await?; + + let clear_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(clear_updated.thread_id, thread.id); + assert_eq!(clear_updated.thread_settings.model, model_id); + assert_eq!(clear_updated.thread_settings.service_tier, None); + + start_text_turn(&mut mcp, thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body + .as_object() + .is_some_and(|object| !object.contains_key("service_tier")) + }), + "future turn did not clear service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + error.error.message, + "`permissions` cannot be combined with `sandboxPolicy`" + ); + Ok(()) +} + +#[tokio::test] +async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-3".to_string()), + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-3"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +async fn send_thread_settings_update( + mcp: &mut McpProcess, + params: ThreadSettingsUpdateParams, +) -> Result<()> { + let request_id = mcp.send_thread_settings_update_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadSettingsUpdateResponse = to_response(response)?; + Ok(()) +} + +async fn start_text_turn(mcp: &mut McpProcess, thread_id: String) -> Result<()> { + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + Ok(()) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_with_turns( + mcp: &mut McpProcess, + thread_id: &str, +) -> Result { + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_settings_updated( + mcp: &mut McpProcess, +) -> Result { + let notification: JSONRPCNotification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let params = notification + .params + .context("thread/settings/updated should include params")?; + Ok(serde_json::from_value(params)?) +} + +async fn received_response_bodies(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut bodies = Vec::new(); + for request in requests { + if request.url.path().ends_with("/responses") { + bodies.push(request.body_json::()?); + } + } + Ok(bodies) +} + +fn service_tier_model_and_tier_id() -> Result<(String, String)> { + let model = all_model_presets() + .iter() + .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty()) + .context("bundled model catalog should include a picker model with service tiers")?; + Ok((model.id.clone(), model.service_tiers[0].id.clone())) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + write_mock_responses_config_toml( + codex_home, + server_uri, + &BTreeMap::default(), + /*auto_compact_limit*/ 200_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + ) +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 3164f16acb85..edf676f15be8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -211,6 +211,7 @@ mod thread_events; mod thread_goal_actions; mod thread_routing; mod thread_session_state; +mod thread_settings; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index d535bf8e3d83..0b2143b429b9 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -62,6 +62,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ThreadGoalCleared(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadSettingsUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), @@ -175,12 +178,46 @@ pub(super) fn server_notification_thread_target( mod tests { use super::ServerNotificationThreadTarget; use super::server_notification_thread_target; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadSettings; + use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::WarningNotification; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; + fn test_thread_settings() -> ThreadSettings { + ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: codex_app_server_protocol::AskForApproval::Never, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: Some(ReasoningEffort::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }, + personality: None, + } + } + #[test] fn warning_notifications_without_threads_are_global() { let notification = ServerNotification::Warning(WarningNotification { @@ -218,4 +255,18 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); } + + #[test] + fn thread_settings_updated_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = + ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: test_thread_settings(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 5b2b5ab23a6b..1f594d64cef0 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -692,6 +692,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 68bd759e2f63..275ff5b57150 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -756,12 +756,18 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); + self.sync_active_thread_reasoning_setting(app_server, effort) + .await; } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); + self.sync_active_thread_model_setting(app_server, model) + .await; } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); + self.sync_active_thread_personality_setting(app_server, personality) + .await; } AppEvent::OpenRealtimeAudioDeviceSelection { kind } => { self.chat_widget.open_realtime_audio_device_selection(kind); @@ -1544,6 +1550,8 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); + self.sync_active_thread_plan_mode_reasoning_setting(app_server) + .await; } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::for_config(&self.config) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index bddf5e544c3d..500b1e37da18 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -59,6 +59,8 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -77,8 +79,10 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -108,6 +112,29 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } +async fn next_thread_settings_updated( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> ThreadSettingsUpdatedNotification { + for _ in 0..20 { + let event = time::timeout( + std::time::Duration::from_secs(/*secs*/ 2), + app_server.next_event(), + ) + .await + .expect("app-server should emit an event") + .expect("app-server event stream should remain open"); + if let codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ) = event + && notification.thread_id == thread_id.to_string() + { + return notification; + } + } + panic!("expected ThreadSettingsUpdated for thread {thread_id}"); +} + #[tokio::test] async fn handle_mcp_inventory_result_clears_committed_loading_cell() { let mut app = make_test_app().await; @@ -3721,6 +3748,8 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -3970,6 +3999,8 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4546,6 +4577,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4610,6 +4643,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4703,6 +4738,8 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5103,6 +5140,8 @@ async fn new_session_requests_shutdown_for_previous_conversation() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5204,6 +5243,294 @@ async fn interrupt_without_active_turn_is_treated_as_handled() { .await; } +#[tokio::test] +async fn override_turn_context_sends_thread_settings_update() { + Box::pin(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + let thread_id = started.session.thread_id; + let initial_model = started.session.model.clone(); + let initial_effort = started.session.reasoning_effort; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + let service_tier = ServiceTier::Fast.request_value().to_string(); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }; + let op = AppCommand::override_turn_context( + /*cwd*/ None, + Some(AskForApproval::OnRequest), + Some(ApprovalsReviewer::AutoReview), + Some(ActivePermissionProfile::new( + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), + /*windows_sandbox_level*/ None, + Some("gpt-5.4".to_string()), + Some(Some(ReasoningEffortConfig::High)), + /*summary*/ None, + Some(Some(service_tier.clone())), + Some(collaboration_mode.clone()), + Some(Personality::Pragmatic), + ); + + let handled = app + .try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op) + .await + .expect("settings update submission should not fail"); + + assert_eq!(handled, true); + assert_eq!( + app.primary_session_configured + .as_ref() + .expect("primary session") + .model, + initial_model, + "thread/settings/update response is only an ack; cached state changes on notification" + ); + + let notification = next_thread_settings_updated(&mut app_server, thread_id).await; + assert_eq!(notification.thread_settings.model, "gpt-5.4"); + assert_eq!( + notification.thread_settings.effort, + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + notification.thread_settings.service_tier, + Some(service_tier.clone()) + ); + assert_eq!( + notification.thread_settings.approval_policy, + AskForApproval::OnRequest + ); + assert_eq!( + notification.thread_settings.approvals_reviewer.to_core(), + ApprovalsReviewer::AutoReview + ); + let notified_mode = ¬ification.thread_settings.collaboration_mode; + assert_eq!(notified_mode.mode, collaboration_mode.mode); + assert_eq!( + notified_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + notified_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!( + notification.thread_settings.personality, + Some(Personality::Pragmatic) + ); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ), + ) + .await; + let updated_session = app + .primary_session_configured + .as_ref() + .expect("primary session should be updated from notification"); + assert_eq!(updated_session.model, initial_model); + assert_eq!(updated_session.reasoning_effort, initial_effort); + let updated_mode = updated_session + .collaboration_mode + .as_deref() + .expect("collaboration mode should be cached"); + assert_eq!(updated_mode.mode, collaboration_mode.mode); + assert_eq!( + updated_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + updated_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!(updated_session.personality, Some(Personality::Pragmatic)); + assert_eq!(updated_session.service_tier, Some(service_tier)); + assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest); + assert_eq!( + updated_session.approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + updated_session + .active_permission_profile + .as_ref() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + ); + }) + .await; +} + +#[tokio::test] +async fn thread_setting_update_params_sync_model_and_default_reasoning() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + + app.chat_widget.set_model("gpt-5.4"); + let params = app + .active_thread_model_setting_update_params("gpt-5.4".to_string()) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.model, Some("gpt-5.4".to_string())); + assert_eq!( + params + .collaboration_mode + .as_ref() + .expect("collaboration mode should sync with model") + .settings + .model, + "gpt-5.4" + ); + + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-plan".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)), + developer_instructions: None, + }); + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let params = app + .active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High)) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.effort, Some(ReasoningEffortConfig::High)); + let collaboration_mode = params + .collaboration_mode + .expect("collaboration mode should sync with reasoning"); + assert_eq!(collaboration_mode.mode, ModeKind::Default); + assert_eq!( + collaboration_mode.settings.reasoning_effort, + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() { + let mut app = make_test_app().await; + let primary_thread_id = ThreadId::new(); + let inactive_thread_id = ThreadId::new(); + let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main")); + let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive")); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-plan".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: Some("draft a plan first".to_string()), + }, + }; + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + primary_session, + Vec::new(), + ), + ); + app.thread_event_channels.insert( + inactive_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + inactive_session, + Vec::new(), + ), + ); + + let notification = ThreadSettingsUpdatedNotification { + thread_id: inactive_thread_id.to_string(), + thread_settings: ThreadSettings { + cwd: test_absolute_path("/tmp/thread-settings"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: "gpt-plan".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: collaboration_mode.settings.reasoning_effort, + summary: None, + collaboration_mode: collaboration_mode.clone(), + personality: Some(Personality::Pragmatic), + }, + }; + app.enqueue_thread_notification( + inactive_thread_id, + ServerNotification::ThreadSettingsUpdated(notification), + ) + .await + .expect("settings notification should be cached"); + + let cached_session = app + .thread_event_channels + .get(&inactive_thread_id) + .expect("inactive thread channel") + .store + .lock() + .await + .session + .clone() + .expect("inactive session should remain cached"); + assert_eq!(cached_session.model, "gpt-test"); + assert_eq!(cached_session.personality, Some(Personality::Pragmatic)); + assert_eq!( + cached_session.collaboration_mode.as_deref(), + Some(&collaboration_mode) + ); + + app.chat_widget.handle_thread_session(cached_session); + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!(app.chat_widget.current_model(), "gpt-plan"); + assert_eq!( + app.chat_widget.current_collaboration_mode().model(), + "gpt-test" + ); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.chat_widget.config_ref().personality, + Some(Personality::Pragmatic) + ); +} + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; @@ -5225,6 +5552,8 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 30f68dc64099..b0c9e7e1be59 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -355,6 +355,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d270bb53ce3f..2c2baf512ddc 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -685,7 +685,11 @@ impl App { self.refresh_in_memory_config_from_disk().await?; Ok(true) } - AppCommand::OverrideTurnContext { .. } => Ok(true), + AppCommand::OverrideTurnContext { .. } => { + self.sync_override_turn_context_settings(app_server, thread_id, op) + .await; + Ok(true) + } AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) @@ -825,6 +829,17 @@ impl App { thread_id: ThreadId, notification: ServerNotification, ) -> Result<()> { + if matches!(notification, ServerNotification::ThreadSettingsUpdated(_)) + && self.primary_thread_id.is_some() + && self.primary_thread_id != Some(thread_id) + && !self.thread_event_channels.contains_key(&thread_id) + { + return Ok(()); + } + if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { + self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) + .await; + } let inferred_session = self .infer_session_for_thread_notification(thread_id, ¬ification) .await; diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4e7f0c6741d7..80aa54efbde1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -78,10 +78,16 @@ impl App { ) -> ThreadSessionState { let permission_profile = self.current_permission_profile(); let active_permission_profile = self.current_active_permission_profile(); - let mut session = self - .primary_session_configured - .clone() - .unwrap_or(ThreadSessionState { + let mut session = if let Some(mut session) = self.primary_session_configured.clone() { + if session.thread_id != thread_id { + // `thread/read` does not include thread settings, so do not carry + // thread-scoped state from the currently active session. + session.collaboration_mode = None; + session.personality = None; + } + session + } else { + ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, @@ -99,10 +105,13 @@ impl App { runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: thread.path.clone(), - }); + } + }; session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); @@ -178,6 +187,8 @@ mod tests { runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs new file mode 100644 index 000000000000..bf9282787998 --- /dev/null +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -0,0 +1,208 @@ +//! Thread settings sync between TUI-local state and app-server thread state. + +use super::App; +use crate::app_command::AppCommand; +use crate::app_server_session::AppServerSession; +use crate::session_state::ThreadSessionState; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::PermissionProfile; + +impl App { + pub(super) async fn sync_active_thread_model_setting( + &mut self, + app_server: &mut AppServerSession, + model: String, + ) { + let Some(params) = self.active_thread_model_setting_update_params(model) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_model_setting_update_params( + &self, + model: String, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some(model), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + effort: Option, + ) { + let Some(params) = self.active_thread_reasoning_setting_update_params(effort) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_reasoning_setting_update_params( + &self, + effort: Option, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + effort, + collaboration_mode: Some(self.chat_widget.current_collaboration_mode().clone()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_plan_mode_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_active_thread_personality_setting( + &mut self, + app_server: &mut AppServerSession, + personality: codex_protocol::config_types::Personality, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + personality: Some(personality), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_override_turn_context_settings( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + op: &AppCommand, + ) { + let AppCommand::OverrideTurnContext { + cwd, + approval_policy, + approvals_reviewer, + active_permission_profile, + windows_sandbox_level: _, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = op + else { + return; + }; + + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + cwd: cwd.clone(), + approval_policy: *approval_policy, + approvals_reviewer: approvals_reviewer.map(AppServerApprovalsReviewer::from), + permissions: active_permission_profile + .as_ref() + .map(|profile| profile.id.clone()), + model: model.clone(), + effort: effort.unwrap_or_default(), + summary: *summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality: *personality, + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn apply_thread_settings_to_cached_session( + &mut self, + thread_id: ThreadId, + settings: &ThreadSettings, + ) { + if self.primary_thread_id == Some(thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + apply_thread_settings_to_session(session, settings); + } + + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + apply_thread_settings_to_session(session, settings); + } + } + } + + async fn send_thread_settings_update( + &mut self, + app_server: &mut AppServerSession, + params: ThreadSettingsUpdateParams, + ) { + if !thread_settings_update_has_changes(¶ms) { + return; + } + if let Err(err) = app_server.thread_settings_update(params).await { + tracing::warn!("failed to update app-server thread settings from TUI: {err}"); + self.chat_widget + .add_error_message(format!("Failed to update thread settings: {err}")); + } + } +} + +fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { + if settings.collaboration_mode.mode == ModeKind::Default { + session.model = settings.model.clone(); + session.reasoning_effort = settings.effort; + } + session.model_provider_id = settings.model_provider.clone(); + session.service_tier = settings.service_tier.clone(); + session.approval_policy = settings.approval_policy; + session.approvals_reviewer = settings.approvals_reviewer.to_core(); + session.permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + session.active_permission_profile = settings.active_permission_profile.clone().map(Into::into); + session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone()); + session.personality = settings.personality; + let mut collaboration_mode = settings.collaboration_mode.clone(); + collaboration_mode + .settings + .model + .clone_from(&settings.model); + collaboration_mode.settings.reasoning_effort = settings.effort; + session.collaboration_mode = Some(Box::new(collaboration_mode)); +} + +fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool { + params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || params.collaboration_mode.is_some() + || params.personality.is_some() +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1bcb4a559736..ed825ded72cb 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -86,6 +86,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::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSource; @@ -120,10 +122,20 @@ use color_eyre::eyre::WrapErr; use std::collections::HashMap; use std::path::PathBuf; +const JSONRPC_INVALID_REQUEST: i64 = -32600; +const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; +const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } +fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { + source.code == JSONRPC_METHOD_NOT_FOUND + || (source.code == JSONRPC_INVALID_REQUEST + && source.message.contains(THREAD_SETTINGS_UPDATE_METHOD)) +} + /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// @@ -150,6 +162,7 @@ pub(crate) struct AppServerSession { next_request_id: i64, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + thread_settings_update_supported: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -189,6 +202,7 @@ impl AppServerSession { next_request_id: 1, remote_cwd_override: None, thread_params_mode, + thread_settings_update_supported: true, } } @@ -525,6 +539,39 @@ impl AppServerSession { .wrap_err("thread/metadata/update failed while syncing git branch") } + pub(crate) async fn thread_settings_update( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> Result<()> { + if !self.thread_settings_update_supported { + return Ok(()); + } + let request_id = self.next_request_id(); + match self + .client + .request_typed::(ClientRequest::ThreadSettingsUpdate { + request_id, + params, + }) + .await + { + Ok(_) => Ok(()), + Err(TypedRequestError::Server { source, .. }) + if is_thread_settings_update_unsupported(&source) => + { + // Older remote app servers can reject this experimental method as + // method-not-found, experimental-capability-gated, or an unknown + // request variant. Treat those as a session-level capability + // downgrade so local TUI setting changes stay best-effort instead + // of showing an error every time the user changes model, effort, + // personality, or mode. + self.thread_settings_update_supported = false; + Ok(()) + } + Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"), + } + } + pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, @@ -1575,6 +1622,8 @@ async fn thread_session_state_from_thread_response( runtime_workspace_roots, instruction_source_paths, reasoning_effort, + collaboration_mode: None, + personality: config.personality, message_history: Some(MessageHistoryMetadata { log_id, entry_count, @@ -1676,6 +1725,37 @@ mod tests { ); } + #[test] + fn thread_settings_update_compat_detects_unsupported_errors() { + let cases = [ + (JSONRPC_METHOD_NOT_FOUND, "method not found", true), + ( + JSONRPC_INVALID_REQUEST, + "thread/settings/update requires experimentalApi capability", + true, + ), + ( + JSONRPC_INVALID_REQUEST, + "Invalid request: unknown variant `thread/settings/update`", + true, + ), + (JSONRPC_INVALID_REQUEST, "invalid thread id", false), + ]; + + for (code, message, expected) in cases { + let source = JSONRPCErrorError { + code, + data: None, + message: message.to_string(), + }; + assert_eq!( + is_thread_settings_update_unsupported(&source), + expected, + "{message}" + ); + } + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 240b72ab9d42..0ba974d88e4e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index 704d5dcac6d3..27d073218ab3 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -173,7 +173,7 @@ impl ChatWidget { ); return; } - self.set_collaboration_mask(collaboration_mode); + self.set_collaboration_mask_from_user_action(collaboration_mode); let should_queue = self.is_plan_streaming_in_tui(); let user_message = UserMessage { text, diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index f0e3efea0e1e..b3b58cbc8243 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -51,6 +51,9 @@ impl ChatWidget { ServerNotification::ThreadGoalCleared(notification) => { self.on_thread_goal_cleared(notification.thread_id.as_str()); } + ServerNotification::ThreadSettingsUpdated(notification) => { + self.on_thread_settings_updated(notification); + } ServerNotification::TurnStarted(notification) => { self.turn_lifecycle.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; @@ -246,6 +249,10 @@ impl ChatWidget { notification: TurnCompletedNotification, replay_kind: Option, ) { + // User-message dedupe only suppresses the app-server echo of a prompt + // this TUI already rendered locally. Once that turn ends, another + // client can submit the same text and it still needs its own user cell. + self.last_rendered_user_message_display = None; match notification.turn.status { TurnStatus::Completed => { self.last_non_retry_error = None; diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 2b8beefdf85a..5a6a600701d4 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -71,18 +71,31 @@ impl ChatWidget { } } self.config.approvals_reviewer = session.approvals_reviewer; + self.config.personality = session.personality; self.status_line_project_root_name_cache = None; let forked_from_id = session.forked_from_id; - let model_for_header = session.model.clone(); - self.session_header.set_model(&model_for_header); + let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model_for_header.clone()), + Some(default_model.clone()), Some(session.reasoning_effort), /*developer_instructions*/ None, ); - if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.model = Some(model_for_header.clone()); - mask.reasoning_effort = Some(session.reasoning_effort); + match session.collaboration_mode.as_deref() { + Some(collaboration_mode) => { + self.set_effective_collaboration_mode(collaboration_mode.clone()); + } + None => { + self.active_collaboration_mask = Self::initial_collaboration_mask( + &self.config, + self.model_catalog.as_ref(), + Some(&default_model), + ); + if let Some(mask) = self.active_collaboration_mask.as_mut() { + mask.reasoning_effort = Some(session.reasoning_effort); + } + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + } } self.refresh_model_display(); self.refresh_status_surfaces(); @@ -91,6 +104,7 @@ impl ChatWidget { self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); + let model_for_header = self.current_model().to_string(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 91abc728db38..0434f7e38661 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -1,6 +1,7 @@ //! Runtime settings state and model/collaboration coordination for `ChatWidget`. use super::*; +use crate::app_event::AppEvent; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -345,6 +346,24 @@ impl ChatWidget { self.effective_reasoning_effort() } + pub(crate) fn on_thread_settings_updated( + &mut self, + notification: ThreadSettingsUpdatedNotification, + ) { + let Ok(thread_id) = ThreadId::from_string(¬ification.thread_id) else { + tracing::warn!( + thread_id = notification.thread_id, + "ignoring app-server ThreadSettingsUpdated with invalid thread_id" + ); + return; + }; + if self.thread_id != Some(thread_id) { + return; + } + + self.apply_thread_settings(notification.thread_settings); + } + #[cfg(test)] pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { self.active_mode_kind() @@ -430,7 +449,7 @@ impl ChatWidget { .unwrap_or(current_effort) } - pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode { + pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.clone(); } @@ -462,6 +481,96 @@ impl ChatWidget { self.refresh_status_line(); } + fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { + let cwd_changed = self.config.cwd != settings.cwd; + self.apply_thread_settings_cwd(settings.cwd.clone()); + self.config.model_provider_id = settings.model_provider.clone(); + self.set_service_tier(settings.service_tier.clone()); + self.set_approval_policy(settings.approval_policy); + self.set_approvals_reviewer(settings.approvals_reviewer.to_core()); + self.config.personality = settings.personality; + + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( + permission_profile, + settings.active_permission_profile.take().map(Into::into), + ); + if let Err(err) = self + .config + .permissions + .set_permission_profile_from_session_snapshot(permission_snapshot.clone()) + { + tracing::warn!(%err, "failed to sync permissions from ThreadSettingsUpdated"); + if let Err(replace_err) = self + .config + .permissions + .replace_permission_profile_from_session_snapshot(permission_snapshot) + { + tracing::error!( + %replace_err, + "failed to replace permissions from ThreadSettingsUpdated after constraint fallback" + ); + } + } + + settings.collaboration_mode.settings.model = settings.model; + settings.collaboration_mode.settings.reasoning_effort = settings.effort; + self.set_effective_collaboration_mode(settings.collaboration_mode); + self.refresh_status_surfaces(); + self.sync_service_tier_commands(); + self.sync_personality_command_enabled(); + if cwd_changed { + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + } + self.refresh_plugin_mentions(); + self.request_redraw(); + } + + fn apply_thread_settings_cwd(&mut self, cwd: AbsolutePathBuf) { + let previous_cwd = std::mem::replace(&mut self.config.cwd, cwd.clone()); + self.current_cwd = Some(cwd.to_path_buf()); + self.status_line_project_root_name_cache = None; + + if !self.config.workspace_roots.contains(&previous_cwd) { + return; + } + + let previous_roots = std::mem::take(&mut self.config.workspace_roots); + self.config.workspace_roots.push(cwd); + for root in previous_roots { + if root != previous_cwd && !self.config.workspace_roots.contains(&root) { + self.config.workspace_roots.push(root); + } + } + self.config + .permissions + .set_workspace_roots(self.config.workspace_roots.clone()); + } + + pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) { + let mode_kind = mode.mode; + let settings = mode.settings; + if mode_kind == ModeKind::Default { + self.current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: settings.clone(), + }; + } + self.active_collaboration_mask = Some(CollaborationModeMask { + name: mode_kind.display_name().to_string(), + mode: Some(mode_kind), + model: Some(settings.model.clone()), + reasoning_effort: Some(settings.reasoning_effort), + developer_instructions: Some(settings.developer_instructions), + }); + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + self.refresh_model_dependent_surfaces(); + } + pub(super) fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { @@ -555,10 +664,15 @@ impl ChatWidget { self.model_catalog.as_ref(), self.active_collaboration_mask.as_ref(), ) { - self.set_collaboration_mask(next_mask); + self.set_collaboration_mask_from_user_action(next_mask); } } + pub(crate) fn set_collaboration_mask_from_user_action(&mut self, mask: CollaborationModeMask) { + self.set_collaboration_mask(mask); + self.submit_collaboration_mode_settings_update(); + } + /// Update the active collaboration mask. /// /// When collaboration modes are enabled and a preset is selected, @@ -609,4 +723,26 @@ impl ChatWidget { } self.request_redraw(); } + + fn submit_collaboration_mode_settings_update(&self) { + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*active_permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + Some(self.effective_collaboration_mode()), + /*personality*/ None, + ), + }); + } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 9d8d49a77cf0..1c9a6da1682f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -90,7 +90,7 @@ impl ChatWidget { return false; } if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - self.set_collaboration_mask(mask); + self.set_collaboration_mask_from_user_action(mask); true } else { self.add_info_message( diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 0b5f98914124..c8d45d2a5910 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,65 @@ use super::*; use pretty_assertions::assert_eq; +fn thread_settings_for_test( + model: &str, + thread_id: ThreadId, +) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification { + codex_app_server_protocol::ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: codex_app_server_protocol::ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + effort: Some(ReasoningEffortConfig::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: model.to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }, + personality: Some(Personality::Pragmatic), + }, + } +} + +fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { + crate::session_state::ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-5.3-codex".to_string(), + model_provider_id: "openai".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/tmp/thread-settings").abs(), + runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()], + instruction_source_paths: Vec::new(), + reasoning_effort: None, + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: None, + } +} + #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; @@ -38,6 +97,98 @@ async fn invalid_url_elicitation_is_declined() { ); } +#[tokio::test] +async fn thread_settings_updated_updates_visible_state_without_transcript() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + set_fast_mode_test_catalog(&mut chat); + let thread_id = ThreadId::new(); + chat.handle_thread_session(configured_thread_session(thread_id)); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.current_service_tier(), + Some(ServiceTier::Fast.request_value()) + ); + assert_eq!( + chat.config_ref().permissions.approval_policy.value(), + AskForApproval::OnRequest.to_core() + ); + assert_eq!( + chat.config_ref().approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + chat.config_ref() + .permissions + .active_permission_profile() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY + ); + assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert!( + drain_insert_history(&mut rx).is_empty(), + "ThreadSettingsUpdated should not render transcript history" + ); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test( + "gpt-5.2", + ThreadId::new(), + )), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); +} + +#[tokio::test] +async fn thread_settings_updated_preserves_default_settings_for_plan_mode() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let thread_id = ThreadId::new(); + let mut session = configured_thread_session(thread_id); + session.model = "gpt-default".to_string(); + session.reasoning_effort = Some(ReasoningEffortConfig::Low); + chat.handle_thread_session(session); + let _ = drain_insert_history(&mut rx); + let default_mode = chat.current_collaboration_mode().clone(); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_model(), "gpt-plan"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!(chat.current_collaboration_mode(), &default_mode); + + let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + assert_eq!(chat.current_model(), "gpt-default"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::Low) + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 4cd4cf45090a..70b5c49b0e97 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -30,6 +30,8 @@ async fn submission_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -135,6 +137,8 @@ async fn submission_includes_configured_active_permission_profile() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -188,6 +192,8 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -231,6 +237,8 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -327,6 +335,8 @@ async fn enter_with_only_remote_images_submits_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -392,6 +402,8 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -431,6 +443,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -470,6 +484,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -512,6 +528,8 @@ async fn submission_prefers_selected_duplicate_skill_path() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d54783615c0b..7988459db233 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -960,6 +960,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 5ba32ead5631..a7ec121dedd5 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -31,6 +31,8 @@ async fn resumed_initial_messages_render_history() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -102,6 +104,8 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -172,6 +176,8 @@ async fn replayed_user_message_preserves_remote_image_urls() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -272,6 +278,8 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -343,6 +351,8 @@ async fn session_configured_preserves_profile_workspace_roots() { runtime_workspace_roots: session_runtime_workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -388,6 +398,8 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -427,6 +439,8 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -483,6 +497,8 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -755,6 +771,8 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -801,6 +819,8 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d682d24d66a7..71483bd39a70 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -584,6 +584,8 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -632,6 +634,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 091f92c3fddf..5fc2fa2ce92e 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1220,6 +1220,8 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -1407,6 +1409,8 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 306a90d7822c..1d5793e82cae 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2493,6 +2493,8 @@ async fn session_configured_clears_goal_status_footer() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index b0b87cdd003e..1307f9a828f3 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -451,6 +451,8 @@ fn session_configured_event(model: &str) -> ThreadSessionState { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index c9d964d620cb..7988ba2264ef 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; use codex_app_server_protocol::AskForApproval; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -47,6 +49,8 @@ pub(crate) struct ThreadSessionState { pub(crate) runtime_workspace_roots: Vec, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, + pub(crate) collaboration_mode: Option>, + pub(crate) personality: Option, pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option,