fix(tui): forward cwd changes through app-server

Forward standalone turn-context overrides from the TUI to loaded app-server threads via `thread/turnContext/update`, so `/cwd` updates the core thread state instead of only local UI state.

Add protocol schemas and regression coverage showing thread-scoped shell commands use the updated cwd after the context override.
This commit is contained in:
Felipe Coury
2026-04-06 15:29:25 -03:00
parent ae617fc469
commit 93154c3378
17 changed files with 1271 additions and 2 deletions
@@ -3106,6 +3106,122 @@
},
"type": "object"
},
"ThreadTurnContextUpdateParams": {
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the approval policy for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on subsequent turns."
},
"cwd": {
"description": "Override the working directory for subsequent turns and thread-scoped operations.",
"type": [
"string",
"null"
]
},
"effort": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the reasoning effort for subsequent turns."
},
"model": {
"description": "Override the model for subsequent turns.",
"type": [
"string",
"null"
]
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
],
"description": "Override the personality for subsequent turns."
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Override the sandbox policy for subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for subsequent turns."
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
],
"description": "Override the reasoning summary for subsequent turns."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadUnarchiveParams": {
"properties": {
"threadId": {
@@ -3605,6 +3721,30 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/turnContext/update"
],
"title": "Thread/turnContext/updateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadTurnContextUpdateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turnContext/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -386,6 +386,30 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/turnContext/update"
],
"title": "Thread/turnContext/updateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadTurnContextUpdateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turnContext/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -14165,6 +14189,129 @@
"title": "ThreadTokenUsageUpdatedNotification",
"type": "object"
},
"ThreadTurnContextUpdateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the approval policy for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/v2/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on subsequent turns."
},
"cwd": {
"description": "Override the working directory for subsequent turns and thread-scoped operations.",
"type": [
"string",
"null"
]
},
"effort": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningEffort"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the reasoning effort for subsequent turns."
},
"model": {
"description": "Override the model for subsequent turns.",
"type": [
"string",
"null"
]
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/v2/Personality"
},
{
"type": "null"
}
],
"description": "Override the personality for subsequent turns."
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Override the sandbox policy for subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/v2/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for subsequent turns."
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/v2/ReasoningSummary"
},
{
"type": "null"
}
],
"description": "Override the reasoning summary for subsequent turns."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnContextUpdateParams",
"type": "object"
},
"ThreadTurnContextUpdateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadTurnContextUpdateResponse",
"type": "object"
},
"ThreadUnarchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -961,6 +961,30 @@
"title": "Thread/name/setRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/turnContext/update"
],
"title": "Thread/turnContext/updateRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadTurnContextUpdateParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/turnContext/updateRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12020,6 +12044,129 @@
"title": "ThreadTokenUsageUpdatedNotification",
"type": "object"
},
"ThreadTurnContextUpdateParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the approval policy for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on subsequent turns."
},
"cwd": {
"description": "Override the working directory for subsequent turns and thread-scoped operations.",
"type": [
"string",
"null"
]
},
"effort": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the reasoning effort for subsequent turns."
},
"model": {
"description": "Override the model for subsequent turns.",
"type": [
"string",
"null"
]
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
],
"description": "Override the personality for subsequent turns."
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Override the sandbox policy for subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for subsequent turns."
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
],
"description": "Override the reasoning summary for subsequent turns."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnContextUpdateParams",
"type": "object"
},
"ThreadTurnContextUpdateResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadTurnContextUpdateResponse",
"type": "object"
},
"ThreadUnarchiveParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -0,0 +1,454 @@
{
"$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"
},
"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`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
"enum": [
"user",
"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"
},
"ReadOnlyAccess": {
"oneOf": [
{
"properties": {
"includePlatformDefaults": {
"default": true,
"type": "boolean"
},
"readableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
},
"type": {
"enum": [
"restricted"
],
"title": "RestrictedReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "RestrictedReadOnlyAccess",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"fullAccess"
],
"title": "FullAccessReadOnlyAccessType",
"type": "string"
}
},
"required": [
"type"
],
"title": "FullAccessReadOnlyAccess",
"type": "object"
}
]
},
"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": {
"access": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"default": {
"type": "fullAccess"
}
},
"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"
},
"readOnlyAccess": {
"allOf": [
{
"$ref": "#/definitions/ReadOnlyAccess"
}
],
"default": {
"type": "fullAccess"
}
},
"type": {
"enum": [
"workspaceWrite"
],
"title": "WorkspaceWriteSandboxPolicyType",
"type": "string"
},
"writableRoots": {
"default": [],
"items": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"type": "array"
}
},
"required": [
"type"
],
"title": "WorkspaceWriteSandboxPolicy",
"type": "object"
}
]
},
"ServiceTier": {
"enum": [
"fast",
"flex"
],
"type": "string"
},
"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"
}
},
"properties": {
"approvalPolicy": {
"anyOf": [
{
"$ref": "#/definitions/AskForApproval"
},
{
"type": "null"
}
],
"description": "Override the approval policy for subsequent turns."
},
"approvalsReviewer": {
"anyOf": [
{
"$ref": "#/definitions/ApprovalsReviewer"
},
{
"type": "null"
}
],
"description": "Override where approval requests are routed for review on subsequent turns."
},
"cwd": {
"description": "Override the working directory for subsequent turns and thread-scoped operations.",
"type": [
"string",
"null"
]
},
"effort": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ReasoningEffort"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the reasoning effort for subsequent turns."
},
"model": {
"description": "Override the model for subsequent turns.",
"type": [
"string",
"null"
]
},
"personality": {
"anyOf": [
{
"$ref": "#/definitions/Personality"
},
{
"type": "null"
}
],
"description": "Override the personality for subsequent turns."
},
"sandboxPolicy": {
"anyOf": [
{
"$ref": "#/definitions/SandboxPolicy"
},
{
"type": "null"
}
],
"description": "Override the sandbox policy for subsequent turns."
},
"serviceTier": {
"anyOf": [
{
"anyOf": [
{
"$ref": "#/definitions/ServiceTier"
},
{
"type": "null"
}
]
},
{
"type": "null"
}
],
"description": "Override the service tier for subsequent turns."
},
"summary": {
"anyOf": [
{
"$ref": "#/definitions/ReasoningSummary"
},
{
"type": "null"
}
],
"description": "Override the reasoning summary for subsequent turns."
},
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadTurnContextUpdateParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadTurnContextUpdateResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
// 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 { CollaborationMode } from "../CollaborationMode";
import type { Personality } from "../Personality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ReasoningSummary } from "../ReasoningSummary";
import type { ServiceTier } from "../ServiceTier";
import type { ApprovalsReviewer } from "./ApprovalsReviewer";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
export type ThreadTurnContextUpdateParams = {threadId: string, /**
* Override the working directory for subsequent turns and thread-scoped
* operations.
*/
cwd?: string | null, /**
* Override the approval policy for subsequent turns.
*/
approvalPolicy?: AskForApproval | null, /**
* Override where approval requests are routed for review on subsequent
* turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, /**
* Override the sandbox policy for subsequent turns.
*/
sandboxPolicy?: SandboxPolicy | null, /**
* Override the model for subsequent turns.
*/
model?: string | null, /**
* Override the reasoning effort for subsequent turns.
*/
effort?: ReasoningEffort | null | null, /**
* Override the reasoning summary for subsequent turns.
*/
summary?: ReasoningSummary | null, /**
* Override the service tier for subsequent turns.
*/
serviceTier?: ServiceTier | null | null, /**
* EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns.
*
* Takes precedence over model, reasoning effort, and developer
* instructions if set.
*/
collaborationMode?: CollaborationMode | null, /**
* Override the personality for subsequent turns.
*/
personality?: Personality | null};
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadTurnContextUpdateResponse = Record<string, never>;
@@ -310,6 +310,8 @@ export type { ThreadStatus } from "./ThreadStatus";
export type { ThreadStatusChangedNotification } from "./ThreadStatusChangedNotification";
export type { ThreadTokenUsage } from "./ThreadTokenUsage";
export type { ThreadTokenUsageUpdatedNotification } from "./ThreadTokenUsageUpdatedNotification";
export type { ThreadTurnContextUpdateParams } from "./ThreadTurnContextUpdateParams";
export type { ThreadTurnContextUpdateResponse } from "./ThreadTurnContextUpdateResponse";
export type { ThreadUnarchiveParams } from "./ThreadUnarchiveParams";
export type { ThreadUnarchiveResponse } from "./ThreadUnarchiveResponse";
export type { ThreadUnarchivedNotification } from "./ThreadUnarchivedNotification";
@@ -280,6 +280,11 @@ client_request_definitions! {
params: v2::ThreadSetNameParams,
response: v2::ThreadSetNameResponse,
},
ThreadTurnContextUpdate => "thread/turnContext/update" {
params: v2::ThreadTurnContextUpdateParams,
inspect_params: true,
response: v2::ThreadTurnContextUpdateResponse,
},
ThreadMetadataUpdate => "thread/metadata/update" {
params: v2::ThreadMetadataUpdateParams,
response: v2::ThreadMetadataUpdateResponse,
@@ -2891,6 +2891,69 @@ pub struct ThreadUnarchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadSetNameResponse {}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadTurnContextUpdateParams {
pub thread_id: String,
/// Override the working directory for subsequent turns and thread-scoped
/// operations.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Override the approval policy for subsequent turns.
#[experimental(nested)]
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
/// Override where approval requests are routed for review on subsequent
/// turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Override the sandbox policy for subsequent turns.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Override the model for subsequent turns.
#[ts(optional = nullable)]
pub model: Option<String>,
/// Override the reasoning effort for subsequent turns.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub effort: Option<Option<ReasoningEffort>>,
/// Override the reasoning summary for subsequent turns.
#[ts(optional = nullable)]
pub summary: Option<ReasoningSummary>,
/// Override the service tier for subsequent turns.
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
/// EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns.
///
/// Takes precedence over model, reasoning effort, and developer
/// instructions if set.
#[experimental("thread/turnContext/update.collaborationMode")]
#[ts(optional = nullable)]
pub collaboration_mode: Option<CollaborationMode>,
/// Override the personality for subsequent turns.
#[ts(optional = nullable)]
pub personality: Option<Personality>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadTurnContextUpdateResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1
View File
@@ -143,6 +143,7 @@ Example with notification opt-out:
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/turnContext/update` — update a loaded thread’s defaults for subsequent turns and thread-scoped operations, such as `cwd`, approval policy, approvals reviewer, sandbox policy, model, reasoning effort, service tier, collaboration mode, or personality; returns `{}` on success.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
@@ -158,6 +158,8 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnContextUpdateParams;
use codex_app_server_protocol::ThreadTurnContextUpdateResponse;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnarchivedNotification;
@@ -739,6 +741,10 @@ impl CodexMessageProcessor {
self.thread_set_name(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadTurnContextUpdate { request_id, params } => {
self.thread_turn_context_update(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadMetadataUpdate { request_id, params } => {
self.thread_metadata_update(to_connection_request_id(request_id), params)
.await;
@@ -2670,6 +2676,75 @@ impl CodexMessageProcessor {
.await;
}
async fn thread_turn_context_update(
&self,
request_id: ConnectionRequestId,
params: ThreadTurnContextUpdateParams,
) {
let ThreadTurnContextUpdateParams {
thread_id,
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} = params;
let (_, thread) = match self.load_thread(&thread_id).await {
Ok(v) => v,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let collaboration_modes_config = CollaborationModesConfig {
default_mode_request_user_input: thread.enabled(Feature::DefaultModeRequestUserInput),
};
let collaboration_mode = collaboration_mode.map(|mode| {
self.normalize_turn_start_collaboration_mode(mode, collaboration_modes_config)
});
match self
.submit_core_op(
&request_id,
thread.as_ref(),
Op::OverrideTurnContext {
cwd,
approval_policy: approval_policy.map(AskForApproval::to_core),
approvals_reviewer: approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
sandbox_policy: sandbox_policy.map(|p| p.to_core()),
windows_sandbox_level: None,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
},
)
.await
{
Ok(_) => {
self.outgoing
.send_response(request_id, ThreadTurnContextUpdateResponse {})
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to update thread turn context: {err}"),
)
.await;
}
}
}
async fn thread_metadata_update(
&self,
request_id: ConnectionRequestId,
@@ -73,6 +73,7 @@ use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadTurnContextUpdateParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -367,6 +368,15 @@ impl McpProcess {
self.send_request("thread/name/set", params).await
}
/// Send a `thread/turnContext/update` JSON-RPC request.
pub async fn send_thread_turn_context_update_request(
&mut self,
params: ThreadTurnContextUpdateParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/turnContext/update", params).await
}
/// Send a `thread/metadata/update` JSON-RPC request.
pub async fn send_thread_metadata_update_request(
&mut self,
@@ -22,6 +22,8 @@ use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadTurnContextUpdateParams;
use codex_app_server_protocol::ThreadTurnContextUpdateResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
@@ -321,6 +323,89 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_shell_command_uses_updated_turn_context_cwd() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let original_workspace = tmp.path().join("original");
std::fs::create_dir(&original_workspace)?;
let updated_workspace = tmp.path().join("updated");
std::fs::create_dir(&updated_workspace)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(
codex_home.as_path(),
&server.uri(),
"never",
&BTreeMap::default(),
)?;
let mut mcp = McpProcess::new(codex_home.as_path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(original_workspace.display().to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let update_id = mcp
.send_thread_turn_context_update_request(ThreadTurnContextUpdateParams {
thread_id: thread.id.clone(),
cwd: Some(updated_workspace.clone()),
..Default::default()
})
.await?;
let update_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(update_id)),
)
.await??;
let _: ThreadTurnContextUpdateResponse =
to_response::<ThreadTurnContextUpdateResponse>(update_resp)?;
let resolved_workspace = std::fs::canonicalize(updated_workspace.as_path())?;
let (shell_command, expected_output) = current_shell_pwd_command(resolved_workspace.as_path());
let shell_id = mcp
.send_thread_shell_command_request(ThreadShellCommandParams {
thread_id: thread.id,
command: shell_command,
})
.await?;
let shell_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(shell_id)),
)
.await??;
let _: ThreadShellCommandResponse = to_response::<ThreadShellCommandResponse>(shell_resp)?;
let started = wait_for_command_execution_started(&mut mcp, /*expected_id*/ None).await?;
let ThreadItem::CommandExecution { id, source, .. } = &started.item else {
unreachable!("helper returns command execution item");
};
let command_id = id.clone();
assert_eq!(source, &CommandExecutionSource::UserShell);
let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?;
let ThreadItem::CommandExecution {
aggregated_output, ..
} = &completed.item
else {
unreachable!("helper returns command execution item");
};
assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str()));
Ok(())
}
fn current_shell_output_command(text: &str) -> Result<(String, String)> {
let command_and_output = match default_user_shell().name() {
"powershell" => {
@@ -339,6 +424,17 @@ fn current_shell_output_command(text: &str) -> Result<(String, String)> {
Ok(command_and_output)
}
fn current_shell_pwd_command(cwd: &Path) -> (String, String) {
match default_user_shell().name() {
"powershell" => (
"(Get-Location).Path".to_string(),
format!("{}\r\n", cwd.display()),
),
"cmd" => ("cd".to_string(), format!("{}\r\n", cwd.display())),
_ => ("pwd".to_string(), format!("{}\n", cwd.display())),
}
}
async fn wait_for_command_execution_started(
mcp: &mut McpProcess,
expected_id: Option<&str>,
+30 -1
View File
@@ -2403,7 +2403,36 @@ impl App {
app_server.reload_user_config().await?;
Ok(true)
}
AppCommandView::OverrideTurnContext { .. } => Ok(true),
AppCommandView::OverrideTurnContext {
cwd,
approval_policy,
approvals_reviewer,
sandbox_policy,
windows_sandbox_level: _,
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
} => {
app_server
.thread_turn_context_update(
thread_id,
(*cwd).clone(),
*approval_policy,
*approvals_reviewer,
(*sandbox_policy).clone(),
(*model).clone(),
*effort,
*summary,
*service_tier,
(*collaboration_mode).clone(),
*personality,
)
.await?;
Ok(true)
}
_ => Ok(false),
}
}
+41
View File
@@ -54,6 +54,8 @@ use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadTurnContextUpdateParams;
use codex_app_server_protocol::ThreadTurnContextUpdateResponse;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::Turn;
@@ -644,6 +646,45 @@ impl AppServerSession {
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn thread_turn_context_update(
&mut self,
thread_id: ThreadId,
cwd: Option<PathBuf>,
approval_policy: Option<AskForApproval>,
approvals_reviewer: Option<codex_protocol::config_types::ApprovalsReviewer>,
sandbox_policy: Option<SandboxPolicy>,
model: Option<String>,
effort: Option<Option<codex_protocol::openai_models::ReasoningEffort>>,
summary: Option<codex_protocol::config_types::ReasoningSummary>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
collaboration_mode: Option<codex_protocol::config_types::CollaborationMode>,
personality: Option<codex_protocol::config_types::Personality>,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadTurnContextUpdateResponse = self
.client
.request_typed(ClientRequest::ThreadTurnContextUpdate {
request_id,
params: ThreadTurnContextUpdateParams {
thread_id: thread_id.to_string(),
cwd,
approval_policy: approval_policy.map(Into::into),
approvals_reviewer: approvals_reviewer.map(Into::into),
sandbox_policy: sandbox_policy.map(Into::into),
model,
effort,
summary,
service_tier,
collaboration_mode,
personality,
},
})
.await
.wrap_err("thread/turnContext/update failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_realtime_start(
&mut self,
thread_id: ThreadId,