From 579abe122b0bb4ad5253c7ea1d91d51da23f9109 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Sat, 16 May 2026 17:07:08 -0700 Subject: [PATCH] Fix turn context update ordering --- .../schema/json/ServerNotification.json | 162 +++++-------- .../codex_app_server_protocol.schemas.json | 122 ++++++++++ .../codex_app_server_protocol.v2.schemas.json | 122 ++++++++++ .../ThreadTurnContextUpdatedNotification.json | 216 +++++++----------- .../v2/ManagedFileSystemPermissions.ts | 6 + .../typescript/v2/NetworkSandboxPolicy.ts | 5 + .../schema/typescript/v2/PermissionProfile.ts | 7 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 1 + .../src/protocol/v2/permissions.rs | 117 ++++++++++ .../src/protocol/v2/thread.rs | 2 +- .../request_processors/thread_processor.rs | 13 +- .../src/request_processors/turn_processor.rs | 130 ++++++++--- codex-rs/app-server/src/thread_state.rs | 12 + .../suite/v2/connection_handling_websocket.rs | 87 ++++++- .../tests/suite/v2/thread_turn_context.rs | 129 +++++++++++ 16 files changed, 863 insertions(+), 271 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 0fa2cf51c1..81609b8df9 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -77,14 +77,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -92,31 +84,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -2211,6 +2178,57 @@ ], "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "McpServerOauthLoginCompletedNotification": { "properties": { "error": { @@ -2470,6 +2488,13 @@ ], "type": "string" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", @@ -2547,13 +2572,12 @@ "PermissionProfile": { "oneOf": [ { - "description": "Codex owns sandbox construction for this profile.", "properties": { "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" + "$ref": "#/definitions/ManagedFileSystemPermissions" }, "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" + "$ref": "#/definitions/NetworkSandboxPolicy" }, "type": { "enum": [ @@ -2572,7 +2596,6 @@ "type": "object" }, { - "description": "Do not apply an outer sandbox.", "properties": { "type": { "enum": [ @@ -2589,10 +2612,9 @@ "type": "object" }, { - "description": "Filesystem isolation is enforced by an external caller.", "properties": { "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" + "$ref": "#/definitions/NetworkSandboxPolicy" }, "type": { "enum": [ @@ -2611,68 +2633,6 @@ } ] }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "Personality": { "enum": [ "none", 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 07e6dab174..b856419658 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 @@ -10430,6 +10430,57 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { @@ -11541,6 +11592,13 @@ }, "type": "object" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NetworkUnixSocketPermission": { "enum": [ "allow", @@ -11639,6 +11697,70 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/v2/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/v2/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/v2/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", 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 26be1070fa..5bda97190b 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 @@ -6959,6 +6959,57 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { @@ -8070,6 +8121,13 @@ }, "type": "object" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NetworkUnixSocketPermission": { "enum": [ "allow", @@ -8168,6 +8226,70 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnContextUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnContextUpdatedNotification.json index bce9b025f8..7146ddf937 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnContextUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnContextUpdatedNotification.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "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": [ @@ -329,89 +296,7 @@ } ] }, - "ModeKind": { - "description": "Initial collaboration mode to use when the TUI starts.", - "enum": [ - "plan", - "default" - ], - "type": "string" - }, - "NetworkAccess": { - "enum": [ - "restricted", - "enabled" - ], - "type": "string" - }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { + "ManagedFileSystemPermissions": { "oneOf": [ { "properties": { @@ -433,7 +318,7 @@ "enum": [ "restricted" ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", + "title": "RestrictedManagedFileSystemPermissionsType", "type": "string" } }, @@ -441,7 +326,7 @@ "entries", "type" ], - "title": "RestrictedPermissionProfileFileSystemPermissions", + "title": "RestrictedManagedFileSystemPermissions", "type": "object" }, { @@ -450,28 +335,103 @@ "enum": [ "unrestricted" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", + "title": "UnrestrictedManagedFileSystemPermissionsType", "type": "string" } }, "required": [ "type" ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", + "title": "UnrestrictedManagedFileSystemPermissions", "type": "object" } ] }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "NetworkAccess": { + "enum": [ + "restricted", "enabled" ], - "type": "object" + "type": "string" + }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] }, "Personality": { "enum": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts new file mode 100644 index 0000000000..72a1153902 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.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 { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; + +export type ManagedFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts new file mode 100644 index 0000000000..9549972b35 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkSandboxPolicy = "enabled" | "restricted"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts new file mode 100644 index 0000000000..f9ffff3285 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts @@ -0,0 +1,7 @@ +// 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 { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions"; +import type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy"; + +export type PermissionProfile = { "type": "managed", fileSystem: ManagedFileSystemPermissions, network: NetworkSandboxPolicy, } | { "type": "disabled" } | { "type": "external", network: NetworkSandboxPolicy, }; 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 aae7732658..b635296e8a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -173,6 +173,7 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse" export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; +export type { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions"; export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; export type { MarketplaceAddParams } from "./MarketplaceAddParams"; export type { MarketplaceAddResponse } from "./MarketplaceAddResponse"; @@ -249,12 +250,14 @@ export type { NetworkDomainPermission } from "./NetworkDomainPermission"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy"; export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2eaf4806f9..b29fcf2db1 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1458,6 +1458,7 @@ server_notification_definitions! { Error => "error" (v2::ErrorNotification), ThreadStarted => "thread/started" (v2::ThreadStartedNotification), ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification), + #[experimental("thread/turnContext/updated")] ThreadTurnContextUpdated => "thread/turnContext/updated" (v2::ThreadTurnContextUpdatedNotification), ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification), ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 3299695497..88bb888f7b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -7,11 +7,14 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -184,6 +187,13 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum NetworkSandboxPolicy from CoreNetworkSandboxPolicy { + Enabled, + Restricted + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "kind", rename_all = "snake_case")] #[ts(tag = "kind")] @@ -289,6 +299,113 @@ impl From for CoreFileSystemSandboxEntry { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ManagedFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for ManagedFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: ManagedFileSystemPermissions) -> Self { + match value { + ManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + file_system: ManagedFileSystemPermissions, + network: NetworkSandboxPolicy, + }, + Disabled, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: NetworkSandboxPolicy, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.to_core(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.to_core(), + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] 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 42807f6711..05051cc9d2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,6 +1,7 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; +use super::PermissionProfile; use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; @@ -15,7 +16,6 @@ use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 21160950ab..b5dd6006fc 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2691,8 +2691,17 @@ impl ThreadRequestProcessor { let thread_state = self .thread_state_manager - .thread_state(existing_thread_id) - .await; + .try_ensure_connection_subscribed( + existing_thread_id, + request_id.connection_id, + /*experimental_raw_events*/ false, + ) + .await + .ok_or_else(|| { + internal_error(format!( + "failed to subscribe connection for running thread {existing_thread_id}" + )) + })?; self.ensure_listener_task_running( existing_thread_id, existing_thread.clone(), 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 a38290748d..d30370bc52 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -85,6 +85,35 @@ fn resolve_runtime_workspace_roots( resolved_roots } +fn effective_workspace_roots( + base_snapshot: &ThreadConfigSnapshot, + effective_cwd: &AbsolutePathBuf, + runtime_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Vec { + if let Some(workspace_roots) = runtime_workspace_roots { + return workspace_roots.to_vec(); + } + + if effective_cwd != &base_snapshot.cwd + && base_snapshot.workspace_roots.contains(&base_snapshot.cwd) + { + let mut retargeted_roots = Vec::with_capacity(base_snapshot.workspace_roots.len()); + for root in &base_snapshot.workspace_roots { + let root = if root == &base_snapshot.cwd { + effective_cwd.clone() + } else { + root.clone() + }; + if !retargeted_roots.contains(&root) { + retargeted_roots.push(root); + } + } + retargeted_roots + } else { + base_snapshot.workspace_roots.clone() + } +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -411,22 +440,22 @@ impl TurnRequestProcessor { } let cwd = request.cwd; + let effective_cwd = cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, base_snapshot.cwd.as_path())) + .unwrap_or_else(|| base_snapshot.cwd.clone()); let runtime_workspace_roots = request .runtime_workspace_roots .clone() .map(|workspace_roots| { - let base_cwd = cwd - .as_ref() - .map(|cwd| { - AbsolutePathBuf::resolve_path_against_base( - cwd, - base_snapshot.cwd.as_path(), - ) - }) - .unwrap_or_else(|| base_snapshot.cwd.clone()); - resolve_runtime_workspace_roots(workspace_roots, &base_cwd) + resolve_runtime_workspace_roots(workspace_roots, &effective_cwd) }); + let effective_workspace_roots = effective_workspace_roots( + base_snapshot, + &effective_cwd, + runtime_workspace_roots.as_deref(), + ); let approval_policy = request.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = request .approvals_reviewer @@ -436,15 +465,12 @@ impl TurnRequestProcessor { if let Some(permissions) = request.permissions { let mut overrides = ConfigOverrides { cwd: cwd.clone(), - workspace_roots: Some(request.runtime_workspace_roots.clone().unwrap_or_else( - || { - base_snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), + workspace_roots: Some( + effective_workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), ..Default::default() @@ -507,7 +533,8 @@ impl TurnRequestProcessor { async fn maybe_emit_turn_context_updated( &self, - thread_id: &str, + thread_id: ThreadId, + api_thread_id: &str, before: &ThreadTurnContext, after: ThreadTurnContext, ) { @@ -515,14 +542,60 @@ impl TurnRequestProcessor { return; } - self.outgoing - .send_server_notification(ServerNotification::ThreadTurnContextUpdated( - ThreadTurnContextUpdatedNotification { - thread_id: thread_id.to_string(), - turn_context: after, - }, - )) + let connection_ids = self + .thread_state_manager + .subscribed_connection_ids(thread_id) .await; + if connection_ids.is_empty() { + return; + } + + self.outgoing + .send_server_notification_to_connections( + &connection_ids, + ServerNotification::ThreadTurnContextUpdated( + ThreadTurnContextUpdatedNotification { + thread_id: api_thread_id.to_string(), + turn_context: after, + }, + ), + ) + .await; + } + + async fn wait_for_pending_turn_contexts( + &self, + thread_id: ThreadId, + ) -> Result<(), JSONRPCErrorError> { + let pending = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + thread_state.track_current_pending_turn_contexts() + }; + + for turn_context_applied in pending { + match tokio::time::timeout(TURN_CONTEXT_OVERRIDE_ACK_TIMEOUT, turn_context_applied) + .await + { + Ok(Ok(Ok(_))) => {} + Ok(Ok(Err(err))) => { + return Err(internal_error(format!( + "failed to apply pending turn context override: {err}" + ))); + } + Ok(Err(_)) => { + return Err(internal_error( + "pending turn context override waiter was cancelled".to_string(), + )); + } + Err(_) => { + return Err(internal_error( + "timed out waiting for pending turn context overrides to apply".to_string(), + )); + } + } + } + Ok(()) } async fn turn_start_inner( @@ -648,6 +721,7 @@ impl TurnRequestProcessor { let after_turn_context = thread_turn_context_from_applied_event(&payload); processor .maybe_emit_turn_context_updated( + thread_id, &api_thread_id, &before_turn_context, after_turn_context, @@ -713,6 +787,7 @@ impl TurnRequestProcessor { .inspect_err(|error| { self.track_error_response(request_id, error, /*error_type*/ None); })?; + self.wait_for_pending_turn_contexts(thread_id).await?; let before_snapshot = thread.config_snapshot().await; let before_turn_context = thread_turn_context_from_snapshot(&before_snapshot); let resolved_overrides = self @@ -783,6 +858,7 @@ impl TurnRequestProcessor { before_turn_context.clone() }; self.maybe_emit_turn_context_updated( + thread_id, ¶ms.thread_id, &before_turn_context, after_turn_context.clone(), diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 39637dcf01..bdff0feb4f 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -147,6 +147,18 @@ impl ThreadState { rx } + pub(crate) fn track_current_pending_turn_contexts( + &mut self, + ) -> Vec> { + let mut receivers = Vec::with_capacity(self.pending_turn_context_waiters.len()); + for waiters in self.pending_turn_context_waiters.values_mut() { + let (tx, rx) = oneshot::channel(); + waiters.push(tx); + receivers.push(rx); + } + receivers + } + pub(crate) fn cancel_pending_turn_context(&mut self, submission_id: &str) { self.pending_turn_context_waiters.remove(submission_id); } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 521cf66bc7..1211a03842 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -7,6 +7,7 @@ use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -107,7 +108,7 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> } #[tokio::test] -async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Result<()> { +async fn websocket_turn_context_updates_stay_on_subscribed_connections() -> 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(), "never")?; @@ -117,9 +118,9 @@ async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Resu let mut ws1 = connect_websocket(bind_addr).await?; let mut ws2 = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws1, /*id*/ 1, "ws_context_owner").await?; + send_initialize_experimental_request(&mut ws1, /*id*/ 1, "ws_context_owner").await?; read_response_for_id(&mut ws1, /*id*/ 1).await?; - send_initialize_request(&mut ws2, /*id*/ 2, "ws_context_peer").await?; + send_initialize_experimental_request(&mut ws2, /*id*/ 2, "ws_context_peer").await?; read_response_for_id(&mut ws2, /*id*/ 2).await?; let thread_id = start_thread(&mut ws1, /*id*/ 3).await?; @@ -141,21 +142,19 @@ async fn websocket_turn_context_updates_broadcast_to_other_connections() -> Resu "thread/turnContext/updated", ) .await?; - let peer_notification = - read_notification_for_method(&mut ws2, "thread/turnContext/updated").await?; let ServerNotification::ThreadTurnContextUpdated(caller) = ServerNotification::try_from(caller_notification)? else { bail!("expected caller thread/turnContext/updated notification"); }; - let ServerNotification::ThreadTurnContextUpdated(peer) = - ServerNotification::try_from(peer_notification)? - else { - bail!("expected peer thread/turnContext/updated notification"); - }; assert_eq!(caller.thread_id, thread_id); - assert_eq!(peer, caller); + assert_no_notification_for_method( + &mut ws2, + "thread/turnContext/updated", + Duration::from_millis(250), + ) + .await?; process .kill() @@ -651,6 +650,32 @@ pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, client_name: &str, +) -> Result<()> { + send_initialize_request_with_capabilities(stream, id, client_name, None).await +} + +async fn send_initialize_experimental_request( + stream: &mut WsClient, + id: i64, + client_name: &str, +) -> Result<()> { + send_initialize_request_with_capabilities( + stream, + id, + client_name, + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + ) + .await +} + +async fn send_initialize_request_with_capabilities( + stream: &mut WsClient, + id: i64, + client_name: &str, + capabilities: Option, ) -> Result<()> { let params = InitializeParams { client_info: ClientInfo { @@ -658,7 +683,7 @@ pub(super) async fn send_initialize_request( title: Some("WebSocket Test Client".to_string()), version: "0.1.0".to_string(), }, - capabilities: None, + capabilities, }; send_request( stream, @@ -883,6 +908,44 @@ pub(super) async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) } } +async fn assert_no_notification_for_method( + stream: &mut WsClient, + method: &str, + wait_for: Duration, +) -> Result<()> { + let deadline = Instant::now() + wait_for; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + + let frame = match timeout(remaining, stream.next()).await { + Ok(Some(Ok(frame))) => frame, + Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"), + Ok(None) => bail!("websocket closed unexpectedly while waiting for notification"), + Err(_) => return Ok(()), + }; + + match frame { + WebSocketMessage::Text(text) => { + let message: JSONRPCMessage = serde_json::from_str(text.as_ref())?; + if let JSONRPCMessage::Notification(notification) = message + && notification.method == method + { + bail!("unexpected notification for method `{method}`"); + } + } + WebSocketMessage::Ping(payload) => { + stream.send(WebSocketMessage::Pong(payload)).await?; + } + WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {} + WebSocketMessage::Close(frame) => bail!("websocket closed unexpectedly: {frame:?}"), + WebSocketMessage::Binary(_) => bail!("unexpected binary websocket frame"), + } + } +} + pub(super) fn create_config_toml( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs b/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs index bb248c102a..237a475f7e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_turn_context.rs @@ -7,6 +7,7 @@ use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionProfile; use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; @@ -20,7 +21,11 @@ use codex_app_server_protocol::ThreadTurnContextUpdatedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput as V2UserInput; use codex_features::Feature; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use tempfile::TempDir; @@ -74,6 +79,31 @@ async fn read_turn_context_updated( Ok(notification) } +fn assert_permission_profile_write_root( + permission_profile: &PermissionProfile, + expected_root: &AbsolutePathBuf, + unexpected_root: &AbsolutePathBuf, +) { + let permission_profile: CorePermissionProfile = permission_profile.clone().into(); + let sandbox_policy = permission_profile.file_system_sandbox_policy(); + assert!( + sandbox_policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!(&entry.path, FileSystemPath::Path { path } if path == expected_root) + }), + "expected permission profile write entries to contain {expected_root:?}; got {:?}", + sandbox_policy.entries + ); + assert!( + !sandbox_policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!(&entry.path, FileSystemPath::Path { path } if path == unexpected_root) + }), + "did not expect permission profile write entries to contain {unexpected_root:?}; got {:?}", + sandbox_policy.entries + ); +} + #[tokio::test] async fn thread_turn_context_update_applies_partial_patch_and_emits_full_state() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -133,6 +163,43 @@ async fn thread_turn_context_update_applies_partial_patch_and_emits_full_state() Ok(()) } +#[tokio::test] +async fn thread_turn_context_update_retargets_permissions_when_cwd_changes() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + let next_cwd = TempDir::new()?; + let next_cwd_abs = AbsolutePathBuf::try_from(next_cwd.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let request_id = mcp + .send_thread_turn_context_update_request(ThreadTurnContextUpdateParams { + thread_id: thread.id, + cwd: Some(next_cwd.path().to_path_buf()), + permissions: Some(PermissionProfileSelectionParams::new(":workspace")), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response = to_response::(response)?; + + assert_eq!(response.turn_context.cwd, next_cwd_abs); + assert_permission_profile_write_root( + &response.turn_context.permission_profile, + &next_cwd_abs, + &thread.cwd, + ); + + Ok(()) +} + #[tokio::test] async fn thread_turn_context_update_clears_service_tier_with_explicit_null() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -200,6 +267,68 @@ async fn thread_turn_context_update_rejects_sandbox_policy_with_permissions() -> Ok(()) } +#[tokio::test] +async fn thread_turn_context_update_waits_for_pending_cwd_before_permissions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("Done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + let next_cwd = TempDir::new()?; + let next_cwd_abs = AbsolutePathBuf::try_from(next_cwd.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).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(), + }], + cwd: Some(next_cwd.path().to_path_buf()), + ..Default::default() + }) + .await?; + let update_request_id = mcp + .send_thread_turn_context_update_request(ThreadTurnContextUpdateParams { + thread_id: thread.id, + permissions: Some(PermissionProfileSelectionParams::new(":workspace")), + ..Default::default() + }) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let update_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_request_id)), + ) + .await??; + let update_response = to_response::(update_response)?; + + assert_eq!(update_response.turn_context.cwd, next_cwd_abs); + assert_permission_profile_write_root( + &update_response.turn_context.permission_profile, + &next_cwd_abs, + &thread.cwd, + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_turn_context_updated_when_overrides_change_defaults() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(vec![