diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 2d137de6ff..5d6d3f7ca0 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -494,9 +494,10 @@ jobs: --package codex-responses-api-proxy \ --package codex-sdk - - name: Stage macOS and Linux installer script + - name: Stage installer scripts run: | cp scripts/install/install.sh dist/install.sh + cp scripts/install/install.ps1 dist/install.ps1 - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/.vscode/settings.json b/.vscode/settings.json index 5adec04a83..1e857367cb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "rust-analyzer.checkOnSave": true, "rust-analyzer.check.command": "clippy", - "rust-analyzer.check.extraArgs": ["--all-features", "--tests"], + "rust-analyzer.check.extraArgs": ["--tests"], "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"], "rust-analyzer.cargo.targetDir": "${workspaceFolder}/codex-rs/target/rust-analyzer", "[rust]": { diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index e5931d4187..4f8801a3c3 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -849,6 +849,7 @@ "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rapidhash\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "font8x8_0.3.1": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\",\"std\"],\"std\":[],\"unicode\":[]}}", + "fontdue_0.9.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"opentype-layout\"],\"name\":\"ttf-parser\",\"req\":\"^0.21\"}],\"features\":{\"default\":[\"simd\",\"hashbrown\"],\"parallel\":[\"rayon\",\"hashbrown\",\"hashbrown/rayon\"],\"simd\":[],\"std\":[]}}", "foreign-types-shared_0.1.1": "{\"dependencies\":[],\"features\":{}}", "foreign-types_0.3.2": "{\"dependencies\":[{\"name\":\"foreign-types-shared\",\"req\":\"^0.1\"}],\"features\":{}}", "form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}", @@ -1420,6 +1421,7 @@ "try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}", "ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}", "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", + "ttf-parser_0.21.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"tiny-skia-path\",\"req\":\"^0.11.4\"},{\"kind\":\"dev\",\"name\":\"xmlwriter\",\"req\":\"^0.1\"}],\"features\":{\"apple-layout\":[],\"default\":[\"std\",\"opentype-layout\",\"apple-layout\",\"variable-fonts\",\"glyph-names\"],\"glyph-names\":[],\"gvar-alloc\":[\"std\"],\"opentype-layout\":[],\"std\":[],\"variable-fonts\":[]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 778735bd24..d2aa88b905 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1440,7 +1440,6 @@ dependencies = [ "codex-chatgpt", "codex-cloud-requirements", "codex-core", - "codex-execpolicy", "codex-feedback", "codex-file-search", "codex-login", @@ -1455,7 +1454,6 @@ dependencies = [ "codex-utils-json-to-toml", "core_test_support", "futures", - "os_info", "owo-colors", "pretty_assertions", "rmcp", @@ -1552,16 +1550,15 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "font8x8", + "fontdue", "image", "ppt-rs", "pretty_assertions", - "reqwest 0.12.28", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tiny-skia", - "tiny_http", "uuid", "zip 2.4.2", ] @@ -4102,6 +4099,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e" +[[package]] +name = "fontdue" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -10568,6 +10575,12 @@ dependencies = [ "termcolor", ] +[[package]] +name = "ttf-parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" + [[package]] name = "tungstenite" version = "0.27.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1f4c1b9648..5ba5bc6474 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -219,6 +219,7 @@ owo-colors = "4.3.0" path-absolutize = "3.1.1" pathdiff = "0.2" font8x8 = "0.3.1" +fontdue = "0.9.3" tiny-skia = "0.11.4" portable-pty = "0.9.0" ppt-rs = "0.2.6" 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 4cf3933e75..ddeee0ce73 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 @@ -5,7 +5,7 @@ "properties": { "read": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -14,7 +14,7 @@ }, "write": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -1382,7 +1382,7 @@ "thread_id": { "allOf": [ { - "$ref": "#/definitions/ThreadId" + "$ref": "#/definitions/v2/ThreadId" } ], "description": "Thread ID of the receiver/new agent." @@ -1420,7 +1420,7 @@ "thread_id": { "allOf": [ { - "$ref": "#/definitions/ThreadId" + "$ref": "#/definitions/v2/ThreadId" } ], "description": "Thread ID of the receiver/new agent." @@ -4732,7 +4732,7 @@ "properties": { "read": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -4741,7 +4741,7 @@ }, "write": { "items": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/v2/AbsolutePathBuf" }, "type": [ "array", @@ -7131,7 +7131,7 @@ "properties": { "content": { "items": { - "$ref": "#/definitions/UserInput" + "$ref": "#/definitions/v2/UserInput" }, "type": "array" }, @@ -7169,7 +7169,7 @@ "phase": { "anyOf": [ { - "$ref": "#/definitions/MessagePhase" + "$ref": "#/definitions/v2/MessagePhase" }, { "type": "null" @@ -7254,7 +7254,7 @@ { "properties": { "action": { - "$ref": "#/definitions/WebSearchAction" + "$ref": "#/definitions/v2/WebSearchAction" }, "id": { "type": "string" 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 new file mode 100644 index 0000000000..1bd5733364 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -0,0 +1,14093 @@ +{ + "$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" + }, + "Account": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyAccountType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyAccount", + "type": "object" + }, + { + "properties": { + "email": { + "type": "string" + }, + "planType": { + "$ref": "#/definitions/PlanType" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "ChatgptAccountType", + "type": "string" + } + }, + "required": [ + "email", + "planType", + "type" + ], + "title": "ChatgptAccount", + "type": "object" + } + ] + }, + "AccountLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "loginId": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ], + "title": "AccountLoginCompletedNotification", + "type": "object" + }, + "AccountRateLimitsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "$ref": "#/definitions/RateLimitSnapshot" + } + }, + "required": [ + "rateLimits" + ], + "title": "AccountRateLimitsUpdatedNotification", + "type": "object" + }, + "AccountUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authMode": { + "anyOf": [ + { + "$ref": "#/definitions/AuthMode" + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + } + }, + "title": "AccountUpdatedNotification", + "type": "object" + }, + "AgentMessageContent": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "Text" + ], + "title": "TextAgentMessageContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextAgentMessageContent", + "type": "object" + } + ] + }, + "AgentMessageDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "AgentMessageDeltaNotification", + "type": "object" + }, + "AgentStatus": { + "description": "Agent lifecycle status, derived from emitted events.", + "oneOf": [ + { + "description": "Agent is waiting for initialization.", + "enum": [ + "pending_init" + ], + "type": "string" + }, + { + "description": "Agent is currently running.", + "enum": [ + "running" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "Agent is done. Contains the final assistant message.", + "properties": { + "completed": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "completed" + ], + "title": "CompletedAgentStatus", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Agent encountered an error.", + "properties": { + "errored": { + "type": "string" + } + }, + "required": [ + "errored" + ], + "title": "ErroredAgentStatus", + "type": "object" + }, + { + "description": "Agent has been shutdown.", + "enum": [ + "shutdown" + ], + "type": "string" + }, + { + "description": "Agent is not found.", + "enum": [ + "not_found" + ], + "type": "string" + } + ] + }, + "AnalyticsConfig": { + "additionalProperties": true, + "properties": { + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": [ + "string", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": [ + "string", + "null" + ] + }, + "termsOfService": { + "type": [ + "string", + "null" + ] + }, + "website": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "isDiscoverableApp" + ], + "type": "object" + }, + "AppConfig": { + "properties": { + "default_tools_approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "default_tools_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "destructive_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "type": [ + "boolean", + "null" + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolsConfig" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "AppInfo": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "distributionChannel": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": [ + "string", + "null" + ] + }, + "isAccessible": { + "default": false, + "type": "boolean" + }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "logoUrl": { + "type": [ + "string", + "null" + ] + }, + "logoUrlDark": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "AppListUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - notification emitted when the app list changes.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "AppListUpdatedNotification", + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developer": { + "type": [ + "string", + "null" + ] + }, + "firstPartyRequiresInstall": { + "type": [ + "boolean", + "null" + ] + }, + "firstPartyType": { + "type": [ + "string", + "null" + ] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": [ + "array", + "null" + ] + }, + "seoDescription": { + "type": [ + "string", + "null" + ] + }, + "showInComposerWhenUnlinked": { + "type": [ + "boolean", + "null" + ] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "version": { + "type": [ + "string", + "null" + ] + }, + "versionId": { + "type": [ + "string", + "null" + ] + }, + "versionNotes": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": [ + "string", + "null" + ] + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "userPrompt": { + "type": "string" + } + }, + "required": [ + "userPrompt" + ], + "type": "object" + }, + "AppToolApproval": { + "enum": [ + "auto", + "prompt", + "approve" + ], + "type": "string" + }, + "AppToolConfig": { + "properties": { + "approval_mode": { + "anyOf": [ + { + "$ref": "#/definitions/AppToolApproval" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "AppToolsConfig": { + "type": "object" + }, + "AppsConfig": { + "properties": { + "_default": { + "anyOf": [ + { + "$ref": "#/definitions/AppsDefaultConfig" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "type": "object" + }, + "AppsDefaultConfig": { + "properties": { + "destructive_enabled": { + "default": true, + "type": "boolean" + }, + "enabled": { + "default": true, + "type": "boolean" + }, + "open_world_enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, + "AppsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - list available apps/connectors.", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "forceRefetch": { + "description": "When true, bypass app caches and fetch the latest data from sources.", + "type": "boolean" + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "threadId": { + "description": "Optional thread id used to evaluate app feature gating from that thread's config.", + "type": [ + "string", + "null" + ] + } + }, + "title": "AppsListParams", + "type": "object" + }, + "AppsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - app list response.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "AppsListResponse", + "type": "object" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "reject": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "reject" + ], + "title": "RejectAskForApproval", + "type": "object" + } + ] + }, + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": [ + "apikey" + ], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": [ + "chatgpt" + ], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": [ + "chatgptAuthTokens" + ], + "type": "string" + } + ] + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "CallToolResult": { + "description": "The server's response to a tool call.", + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "isError": { + "type": [ + "boolean", + "null" + ] + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "CancelLoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "loginId": { + "type": "string" + } + }, + "required": [ + "loginId" + ], + "title": "CancelLoginAccountParams", + "type": "object" + }, + "CancelLoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/CancelLoginAccountStatus" + } + }, + "required": [ + "status" + ], + "title": "CancelLoginAccountResponse", + "type": "object" + }, + "CancelLoginAccountStatus": { + "enum": [ + "canceled", + "notFound" + ], + "type": "string" + }, + "ClientInfo": { + "properties": { + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ClientRequest": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Request from the client to the server.", + "oneOf": [ + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "initialize" + ], + "title": "InitializeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/InitializeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "InitializeRequest", + "type": "object" + }, + { + "description": "NEW APIs", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/start" + ], + "title": "Thread/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/resume" + ], + "title": "Thread/resumeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadResumeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/resumeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/fork" + ], + "title": "Thread/forkRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadForkParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/forkRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/archive" + ], + "title": "Thread/archiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/archiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unsubscribe" + ], + "title": "Thread/unsubscribeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnsubscribeParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unsubscribeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/name/set" + ], + "title": "Thread/name/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSetNameParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/name/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/unarchive" + ], + "title": "Thread/unarchiveRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchiveParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/unarchiveRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/compact/start" + ], + "title": "Thread/compact/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadCompactStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/compact/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/rollback" + ], + "title": "Thread/rollbackRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRollbackParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/rollbackRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/list" + ], + "title": "Thread/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/loaded/list" + ], + "title": "Thread/loaded/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadLoadedListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/loaded/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/read" + ], + "title": "Thread/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/list" + ], + "title": "Skills/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/list" + ], + "title": "Skills/remote/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/remote/export" + ], + "title": "Skills/remote/exportRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsRemoteWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/remote/exportRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "app/list" + ], + "title": "App/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppsListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "App/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/config/write" + ], + "title": "Skills/config/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsConfigWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/config/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/start" + ], + "title": "Turn/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/steer" + ], + "title": "Turn/steerRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnSteerParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/steerRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "turn/interrupt" + ], + "title": "Turn/interruptRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnInterruptParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Turn/interruptRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "review/start" + ], + "title": "Review/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReviewStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Review/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "model/list" + ], + "title": "Model/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Model/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/list" + ], + "title": "ExperimentalFeature/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServer/oauth/login" + ], + "title": "McpServer/oauth/loginRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServer/oauth/loginRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/mcpServer/reload" + ], + "title": "Config/mcpServer/reloadRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Config/mcpServer/reloadRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "mcpServerStatus/list" + ], + "title": "McpServerStatus/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ListMcpServerStatusParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "McpServerStatus/listRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/setupStart" + ], + "title": "WindowsSandbox/setupStartRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupStartParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "WindowsSandbox/setupStartRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/start" + ], + "title": "Account/login/startRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/LoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/startRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/login/cancel" + ], + "title": "Account/login/cancelRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CancelLoginAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/login/cancelRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/logout" + ], + "title": "Account/logoutRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/logoutRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/rateLimits/read" + ], + "title": "Account/rateLimits/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/rateLimits/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "feedback/upload" + ], + "title": "Feedback/uploadRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FeedbackUploadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Feedback/uploadRequest", + "type": "object" + }, + { + "description": "Execute a command (argv vector) under the server's sandbox.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "command/exec" + ], + "title": "Command/execRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Command/execRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/read" + ], + "title": "Config/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigReadParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/detect" + ], + "title": "ExternalAgentConfig/detectRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigDetectParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/detectRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "externalAgentConfig/import" + ], + "title": "ExternalAgentConfig/importRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExternalAgentConfig/importRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/value/write" + ], + "title": "Config/value/writeRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigValueWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/value/writeRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "config/batchWrite" + ], + "title": "Config/batchWriteRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigBatchWriteParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Config/batchWriteRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "configRequirements/read" + ], + "title": "ConfigRequirements/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "ConfigRequirements/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/read" + ], + "title": "Account/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GetAccountParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Account/readRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fuzzyFileSearch" + ], + "title": "FuzzyFileSearchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "FuzzyFileSearchRequest", + "type": "object" + } + ], + "title": "ClientRequest" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "usageLimitExceeded", + "serverOverloaded", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "httpConnectionFailed" + ], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamConnectionFailed" + ], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseStreamDisconnected" + ], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + } + }, + "required": [ + "responseTooManyFailedAttempts" + ], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentRef": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "thread_id" + ], + "type": "object" + }, + "CollabAgentState": { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentStatusEntry": { + "properties": { + "agent_nickname": { + "description": "Optional nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the agent." + }, + "thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver/new agent." + } + }, + "required": [ + "status", + "thread_id" + ], + "type": "object" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "CollaborationModeMask": { + "description": "EXPERIMENTAL - collaboration mode preset metadata for clients.", + "properties": { + "mode": { + "anyOf": [ + { + "$ref": "#/definitions/ModeKind" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "name", + "path", + "type" + ], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "listFiles" + ], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": [ + "command", + "type" + ], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ] + }, + "timeoutMs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "command" + ], + "title": "CommandExecParams", + "type": "object" + }, + "CommandExecResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "exitCode": { + "format": "int32", + "type": "integer" + }, + "stderr": { + "type": "string" + }, + "stdout": { + "type": "string" + } + }, + "required": [ + "exitCode", + "stderr", + "stdout" + ], + "title": "CommandExecResponse", + "type": "object" + }, + "CommandExecutionOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "CommandExecutionOutputDeltaNotification", + "type": "object" + }, + "CommandExecutionStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "Config": { + "additionalProperties": true, + "properties": { + "analytics": { + "anyOf": [ + { + "$ref": "#/definitions/AnalyticsConfig" + }, + { + "type": "null" + } + ] + }, + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "compact_prompt": { + "type": [ + "string", + "null" + ] + }, + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "forced_chatgpt_workspace_id": { + "type": [ + "string", + "null" + ] + }, + "forced_login_method": { + "anyOf": [ + { + "$ref": "#/definitions/ForcedLoginMethod" + }, + { + "type": "null" + } + ] + }, + "instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_auto_compact_token_limit": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "profile": { + "type": [ + "string", + "null" + ] + }, + "profiles": { + "additionalProperties": { + "$ref": "#/definitions/ProfileV2" + }, + "default": {}, + "type": "object" + }, + "review_model": { + "type": [ + "string", + "null" + ] + }, + "sandbox_mode": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "sandbox_workspace_write": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxWorkspaceWrite" + }, + { + "type": "null" + } + ] + }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "tools": { + "anyOf": [ + { + "$ref": "#/definitions/ToolsV2" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ConfigBatchWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "edits": { + "items": { + "$ref": "#/definitions/ConfigEdit" + }, + "type": "array" + }, + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "edits" + ], + "title": "ConfigBatchWriteParams", + "type": "object" + }, + "ConfigEdit": { + "properties": { + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "type": "object" + }, + "ConfigLayer": { + "properties": { + "config": true, + "disabledReason": { + "type": [ + "string", + "null" + ] + }, + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "config", + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerMetadata": { + "properties": { + "name": { + "$ref": "#/definitions/ConfigLayerSource" + }, + "version": { + "type": "string" + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, + "ConfigLayerSource": { + "oneOf": [ + { + "description": "Managed preferences layer delivered by MDM (macOS only).", + "properties": { + "domain": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "enum": [ + "mdm" + ], + "title": "MdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "domain", + "key", + "type" + ], + "title": "MdmConfigLayerSource", + "type": "object" + }, + { + "description": "Managed config layer from a file (usually `managed_config.toml`).", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the system config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "system" + ], + "title": "SystemConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "SystemConfigLayerSource", + "type": "object" + }, + { + "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", + "properties": { + "file": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "This is the path to the user's config.toml file, though it is not guaranteed to exist." + }, + "type": { + "enum": [ + "user" + ], + "title": "UserConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "UserConfigLayerSource", + "type": "object" + }, + { + "description": "Path to a .codex/ folder within a project. There could be multiple of these between `cwd` and the project/repo root.", + "properties": { + "dotCodexFolder": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "project" + ], + "title": "ProjectConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "dotCodexFolder", + "type" + ], + "title": "ProjectConfigLayerSource", + "type": "object" + }, + { + "description": "Session-layer overrides supplied via `-c`/`--config`.", + "properties": { + "type": { + "enum": [ + "sessionFlags" + ], + "title": "SessionFlagsConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SessionFlagsConfigLayerSource", + "type": "object" + }, + { + "description": "`managed_config.toml` was designed to be a config that was loaded as the last layer on top of everything else. This scheme did not quite work out as intended, but we keep this variant as a \"best effort\" while we phase out `managed_config.toml` in favor of `requirements.toml`.", + "properties": { + "file": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "legacyManagedConfigTomlFromFile" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "file", + "type" + ], + "title": "LegacyManagedConfigTomlFromFileConfigLayerSource", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "legacyManagedConfigTomlFromMdm" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "LegacyManagedConfigTomlFromMdmConfigLayerSource", + "type": "object" + } + ] + }, + "ConfigReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwd": { + "description": "Optional working directory to resolve project config layers. If specified, return the effective config as seen from that directory (i.e., including any project layers between `cwd` and the project/repo root).", + "type": [ + "string", + "null" + ] + }, + "includeLayers": { + "default": false, + "type": "boolean" + } + }, + "title": "ConfigReadParams", + "type": "object" + }, + "ConfigReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "config": { + "$ref": "#/definitions/Config" + }, + "layers": { + "items": { + "$ref": "#/definitions/ConfigLayer" + }, + "type": [ + "array", + "null" + ] + }, + "origins": { + "additionalProperties": { + "$ref": "#/definitions/ConfigLayerMetadata" + }, + "type": "object" + } + }, + "required": [ + "config", + "origins" + ], + "title": "ConfigReadResponse", + "type": "object" + }, + "ConfigRequirements": { + "properties": { + "allowedApprovalPolicies": { + "items": { + "$ref": "#/definitions/AskForApproval" + }, + "type": [ + "array", + "null" + ] + }, + "allowedSandboxModes": { + "items": { + "$ref": "#/definitions/SandboxMode" + }, + "type": [ + "array", + "null" + ] + }, + "allowedWebSearchModes": { + "items": { + "$ref": "#/definitions/WebSearchMode" + }, + "type": [ + "array", + "null" + ] + }, + "enforceResidency": { + "anyOf": [ + { + "$ref": "#/definitions/ResidencyRequirement" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ConfigRequirementsReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requirements": { + "anyOf": [ + { + "$ref": "#/definitions/ConfigRequirements" + }, + { + "type": "null" + } + ], + "description": "Null if no requirements are configured (e.g. no requirements.toml/MDM entries)." + } + }, + "title": "ConfigRequirementsReadResponse", + "type": "object" + }, + "ConfigValueWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedVersion": { + "type": [ + "string", + "null" + ] + }, + "filePath": { + "description": "Path to the config file to write; defaults to the user's `config.toml` when omitted.", + "type": [ + "string", + "null" + ] + }, + "keyPath": { + "type": "string" + }, + "mergeStrategy": { + "$ref": "#/definitions/MergeStrategy" + }, + "value": true + }, + "required": [ + "keyPath", + "mergeStrategy", + "value" + ], + "title": "ConfigValueWriteParams", + "type": "object" + }, + "ConfigWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance or error details.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "Optional path to the config file that triggered the warning.", + "type": [ + "string", + "null" + ] + }, + "range": { + "anyOf": [ + { + "$ref": "#/definitions/TextRange" + }, + { + "type": "null" + } + ], + "description": "Optional range for the error location inside the config file." + }, + "summary": { + "description": "Concise summary of the warning.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + "ConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "filePath": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonical path to the config file that was written." + }, + "overriddenMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/OverriddenMetadata" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/WriteStatus" + }, + "version": { + "type": "string" + } + }, + "required": [ + "filePath", + "status", + "version" + ], + "title": "ConfigWriteResponse", + "type": "object" + }, + "ContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextContentItem", + "type": "object" + }, + { + "properties": { + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageContentItem", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "output_text" + ], + "title": "OutputTextContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "OutputTextContentItem", + "type": "object" + } + ] + }, + "ContextCompactedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "ContextCompactedNotification", + "type": "object" + }, + "CreditsSnapshot": { + "properties": { + "balance": { + "type": [ + "string", + "null" + ] + }, + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + } + }, + "required": [ + "hasCredits", + "unlimited" + ], + "type": "object" + }, + "CustomPrompt": { + "properties": { + "argument_hint": { + "type": [ + "string", + "null" + ] + }, + "content": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "content", + "name", + "path" + ], + "type": "object" + }, + "DeprecationNoticeNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + } + }, + "required": [ + "summary" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + "Duration": { + "properties": { + "nanos": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "secs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "nanos", + "secs" + ], + "type": "object" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "inputText" + ], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": [ + "inputImage" + ], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "imageUrl", + "type" + ], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "DynamicToolSpec": { + "properties": { + "description": { + "type": "string" + }, + "inputSchema": true, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "inputSchema", + "name" + ], + "type": "object" + }, + "ErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "$ref": "#/definitions/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "type": "boolean" + } + }, + "required": [ + "error", + "threadId", + "turnId", + "willRetry" + ], + "title": "ErrorNotification", + "type": "object" + }, + "EventMsg": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Response event from the agent NOTE: Make sure none of these values have optional types, as it will mess up the extension code-gen.", + "oneOf": [ + { + "description": "Error while executing a submission", + "properties": { + "codex_error_info": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "type": "string" + }, + "type": { + "enum": [ + "error" + ], + "title": "ErrorEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "ErrorEventMsg", + "type": "object" + }, + { + "description": "Warning issued while processing a submission. Unlike `Error`, this indicates the turn continued but the user should still be notified.", + "properties": { + "message": { + "type": "string" + }, + "type": { + "enum": [ + "warning" + ], + "title": "WarningEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "WarningEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle start event.", + "properties": { + "session_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_started" + ], + "title": "RealtimeConversationStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationStartedEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation streaming payload event.", + "properties": { + "payload": { + "$ref": "#/definitions/RealtimeEvent" + }, + "type": { + "enum": [ + "realtime_conversation_realtime" + ], + "title": "RealtimeConversationRealtimeEventMsgType", + "type": "string" + } + }, + "required": [ + "payload", + "type" + ], + "title": "RealtimeConversationRealtimeEventMsg", + "type": "object" + }, + { + "description": "Realtime conversation lifecycle close event.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "realtime_conversation_closed" + ], + "title": "RealtimeConversationClosedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RealtimeConversationClosedEventMsg", + "type": "object" + }, + { + "description": "Model routing changed from the requested model to a different model.", + "properties": { + "from_model": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "to_model": { + "type": "string" + }, + "type": { + "enum": [ + "model_reroute" + ], + "title": "ModelRerouteEventMsgType", + "type": "string" + } + }, + "required": [ + "from_model", + "reason", + "to_model", + "type" + ], + "title": "ModelRerouteEventMsg", + "type": "object" + }, + { + "description": "Conversation history was compacted (either automatically or manually).", + "properties": { + "type": { + "enum": [ + "context_compacted" + ], + "title": "ContextCompactedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactedEventMsg", + "type": "object" + }, + { + "description": "Conversation history was rolled back by dropping the last N user turns.", + "properties": { + "num_turns": { + "description": "Number of user turns that were removed from context.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "thread_rolled_back" + ], + "title": "ThreadRolledBackEventMsgType", + "type": "string" + } + }, + "required": [ + "num_turns", + "type" + ], + "title": "ThreadRolledBackEventMsg", + "type": "object" + }, + { + "description": "Agent has started a turn. v1 wire format uses `task_started`; accept `turn_started` for v2 interop.", + "properties": { + "collaboration_mode_kind": { + "allOf": [ + { + "$ref": "#/definitions/ModeKind" + } + ], + "default": "default" + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "task_started" + ], + "title": "TaskStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "turn_id", + "type" + ], + "title": "TaskStartedEventMsg", + "type": "object" + }, + { + "description": "Agent has completed all actions. v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop.", + "properties": { + "last_agent_message": { + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "task_complete" + ], + "title": "TaskCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "turn_id", + "type" + ], + "title": "TaskCompleteEventMsg", + "type": "object" + }, + { + "description": "Usage update for the current session, including totals and last turn. Optional means unknown — UIs should not display when `None`.", + "properties": { + "info": { + "anyOf": [ + { + "$ref": "#/definitions/TokenUsageInfo" + }, + { + "type": "null" + } + ] + }, + "rate_limits": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitSnapshot" + }, + { + "type": "null" + } + ] + }, + "type": { + "enum": [ + "token_count" + ], + "title": "TokenCountEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "TokenCountEventMsg", + "type": "object" + }, + { + "description": "Agent text output message", + "properties": { + "message": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": [ + "agent_message" + ], + "title": "AgentMessageEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "AgentMessageEventMsg", + "type": "object" + }, + { + "description": "User/system input message (what was sent to the model)", + "properties": { + "images": { + "description": "Image URLs sourced from `UserInput::Image`. These are safe to replay in legacy UI history events and correspond to images sent to the model.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "local_images": { + "default": [], + "description": "Local file paths sourced from `UserInput::LocalImage`. These are kept so the UI can reattach images when editing history, and should not be sent to the model or treated as API-ready URLs.", + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `message` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "user_message" + ], + "title": "UserMessageEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "UserMessageEventMsg", + "type": "object" + }, + { + "description": "Agent text output delta message", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message_delta" + ], + "title": "AgentMessageDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentMessageDeltaEventMsg", + "type": "object" + }, + { + "description": "Reasoning event from agent.", + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning" + ], + "title": "AgentReasoningEventMsgType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "AgentReasoningEventMsg", + "type": "object" + }, + { + "description": "Agent reasoning delta event from agent.", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_delta" + ], + "title": "AgentReasoningDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentReasoningDeltaEventMsg", + "type": "object" + }, + { + "description": "Raw chain-of-thought from agent.", + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_raw_content" + ], + "title": "AgentReasoningRawContentEventMsgType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "AgentReasoningRawContentEventMsg", + "type": "object" + }, + { + "description": "Agent reasoning content delta event from agent.", + "properties": { + "delta": { + "type": "string" + }, + "type": { + "enum": [ + "agent_reasoning_raw_content_delta" + ], + "title": "AgentReasoningRawContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "type" + ], + "title": "AgentReasoningRawContentDeltaEventMsg", + "type": "object" + }, + { + "description": "Signaled when the model begins a new reasoning summary section (e.g., a new titled block).", + "properties": { + "item_id": { + "default": "", + "type": "string" + }, + "summary_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "type": { + "enum": [ + "agent_reasoning_section_break" + ], + "title": "AgentReasoningSectionBreakEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AgentReasoningSectionBreakEventMsg", + "type": "object" + }, + { + "description": "Ack the client's configure message.", + "properties": { + "approval_policy": { + "allOf": [ + { + "$ref": "#/definitions/AskForApproval" + } + ], + "description": "When to escalate for approval for execution" + }, + "cwd": { + "description": "Working directory that should be treated as the *root* of the session.", + "type": "string" + }, + "forked_from_id": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadId" + }, + { + "type": "null" + } + ] + }, + "history_entry_count": { + "description": "Current number of entries in the history log.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "history_log_id": { + "description": "Identifier of the history log file (inode on Unix, 0 otherwise).", + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "initial_messages": { + "description": "Optional initial messages (as events) for resumed sessions. When present, UIs can use these to seed the history.", + "items": { + "$ref": "#/definitions/EventMsg" + }, + "type": [ + "array", + "null" + ] + }, + "model": { + "description": "Tell the client what model is being queried.", + "type": "string" + }, + "model_provider_id": { + "type": "string" + }, + "network_proxy": { + "anyOf": [ + { + "$ref": "#/definitions/SessionNetworkProxyRuntime" + }, + { + "type": "null" + } + ], + "description": "Runtime proxy bind addresses, when the managed proxy was started for this session." + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "The effort the model is putting into reasoning about the user's request." + }, + "rollout_path": { + "description": "Path in which the rollout is stored. Can be `None` for ephemeral threads", + "type": [ + "string", + "null" + ] + }, + "sandbox_policy": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "How to sandbox commands executed in the system" + }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "session_id": { + "$ref": "#/definitions/ThreadId" + }, + "thread_name": { + "description": "Optional user-facing thread name (may be unset).", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "session_configured" + ], + "title": "SessionConfiguredEventMsgType", + "type": "string" + } + }, + "required": [ + "approval_policy", + "cwd", + "history_entry_count", + "history_log_id", + "model", + "model_provider_id", + "sandbox_policy", + "session_id", + "type" + ], + "title": "SessionConfiguredEventMsg", + "type": "object" + }, + { + "description": "Updated session metadata (e.g., thread name changes).", + "properties": { + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "thread_name": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "thread_name_updated" + ], + "title": "ThreadNameUpdatedEventMsgType", + "type": "string" + } + }, + "required": [ + "thread_id", + "type" + ], + "title": "ThreadNameUpdatedEventMsg", + "type": "object" + }, + { + "description": "Incremental MCP startup progress updates.", + "properties": { + "server": { + "description": "Server name being started.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/McpStartupStatus" + } + ], + "description": "Current startup status." + }, + "type": { + "enum": [ + "mcp_startup_update" + ], + "title": "McpStartupUpdateEventMsgType", + "type": "string" + } + }, + "required": [ + "server", + "status", + "type" + ], + "title": "McpStartupUpdateEventMsg", + "type": "object" + }, + { + "description": "Aggregate MCP startup completion summary.", + "properties": { + "cancelled": { + "items": { + "type": "string" + }, + "type": "array" + }, + "failed": { + "items": { + "$ref": "#/definitions/McpStartupFailure" + }, + "type": "array" + }, + "ready": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "mcp_startup_complete" + ], + "title": "McpStartupCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "cancelled", + "failed", + "ready", + "type" + ], + "title": "McpStartupCompleteEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Identifier so this can be paired with the McpToolCallEnd event.", + "type": "string" + }, + "invocation": { + "$ref": "#/definitions/McpInvocation" + }, + "type": { + "enum": [ + "mcp_tool_call_begin" + ], + "title": "McpToolCallBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "invocation", + "type" + ], + "title": "McpToolCallBeginEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Identifier for the corresponding McpToolCallBegin that finished.", + "type": "string" + }, + "duration": { + "$ref": "#/definitions/Duration" + }, + "invocation": { + "$ref": "#/definitions/McpInvocation" + }, + "result": { + "allOf": [ + { + "$ref": "#/definitions/Result_of_CallToolResult_or_String" + } + ], + "description": "Result of the tool call. Note this could be an error." + }, + "type": { + "enum": [ + "mcp_tool_call_end" + ], + "title": "McpToolCallEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "duration", + "invocation", + "result", + "type" + ], + "title": "McpToolCallEndEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "type": { + "enum": [ + "web_search_begin" + ], + "title": "WebSearchBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "type" + ], + "title": "WebSearchBeginEventMsg", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/WebSearchAction" + }, + "call_id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "web_search_end" + ], + "title": "WebSearchEndEventMsgType", + "type": "string" + } + }, + "required": [ + "action", + "call_id", + "query", + "type" + ], + "title": "WebSearchEndEventMsg", + "type": "object" + }, + { + "description": "Notification that the server is about to execute a command.", + "properties": { + "call_id": { + "description": "Identifier so this can be paired with the ExecCommandEnd event.", + "type": "string" + }, + "command": { + "description": "The command to be executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory if not the default cwd for the agent.", + "type": "string" + }, + "interaction_input": { + "description": "Raw input sent to a unified exec session (if this is an interaction event).", + "type": [ + "string", + "null" + ] + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "process_id": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandSource" + } + ], + "default": "agent", + "description": "Where the command originated. Defaults to Agent for backward compatibility." + }, + "turn_id": { + "description": "Turn ID that this command belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "exec_command_begin" + ], + "title": "ExecCommandBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "parsed_cmd", + "turn_id", + "type" + ], + "title": "ExecCommandBeginEventMsg", + "type": "object" + }, + { + "description": "Incremental chunk of output from a running command.", + "properties": { + "call_id": { + "description": "Identifier for the ExecCommandBegin that produced this chunk.", + "type": "string" + }, + "chunk": { + "description": "Raw bytes from the stream (may not be valid UTF-8).", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ExecOutputStream" + } + ], + "description": "Which stream produced this chunk." + }, + "type": { + "enum": [ + "exec_command_output_delta" + ], + "title": "ExecCommandOutputDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "chunk", + "stream", + "type" + ], + "title": "ExecCommandOutputDeltaEventMsg", + "type": "object" + }, + { + "description": "Terminal interaction for an in-progress command (stdin sent and stdout observed).", + "properties": { + "call_id": { + "description": "Identifier for the ExecCommandBegin that produced this chunk.", + "type": "string" + }, + "process_id": { + "description": "Process id associated with the running command.", + "type": "string" + }, + "stdin": { + "description": "Stdin sent to the running session.", + "type": "string" + }, + "type": { + "enum": [ + "terminal_interaction" + ], + "title": "TerminalInteractionEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "process_id", + "stdin", + "type" + ], + "title": "TerminalInteractionEventMsg", + "type": "object" + }, + { + "properties": { + "aggregated_output": { + "default": "", + "description": "Captured aggregated output", + "type": "string" + }, + "call_id": { + "description": "Identifier for the ExecCommandBegin that finished.", + "type": "string" + }, + "command": { + "description": "The command that was executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory if not the default cwd for the agent.", + "type": "string" + }, + "duration": { + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ], + "description": "The duration of the command execution." + }, + "exit_code": { + "description": "The command's exit code.", + "format": "int32", + "type": "integer" + }, + "formatted_output": { + "description": "Formatted output from the command, as seen by the model.", + "type": "string" + }, + "interaction_input": { + "description": "Raw input sent to a unified exec session (if this is an interaction event).", + "type": [ + "string", + "null" + ] + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "process_id": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandSource" + } + ], + "default": "agent", + "description": "Where the command originated. Defaults to Agent for backward compatibility." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ExecCommandStatus" + } + ], + "description": "Completion status for this command execution." + }, + "stderr": { + "description": "Captured stderr", + "type": "string" + }, + "stdout": { + "description": "Captured stdout", + "type": "string" + }, + "turn_id": { + "description": "Turn ID that this command belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "exec_command_end" + ], + "title": "ExecCommandEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "duration", + "exit_code", + "formatted_output", + "parsed_cmd", + "status", + "stderr", + "stdout", + "turn_id", + "type" + ], + "title": "ExecCommandEndEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent attached a local image via the view_image tool.", + "properties": { + "call_id": { + "description": "Identifier for the originating tool call.", + "type": "string" + }, + "path": { + "description": "Local filesystem path provided to the tool.", + "type": "string" + }, + "type": { + "enum": [ + "view_image_tool_call" + ], + "title": "ViewImageToolCallEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "path", + "type" + ], + "title": "ViewImageToolCallEventMsg", + "type": "object" + }, + { + "properties": { + "additional_permissions": { + "anyOf": [ + { + "$ref": "#/definitions/PermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional additional filesystem permissions requested for this command." + }, + "approval_id": { + "description": "Identifier for this specific approval callback.\n\nWhen absent, the approval is for the command item itself (`call_id`). This is present for subcommand approvals (via execve intercept).", + "type": [ + "string", + "null" + ] + }, + "available_decisions": { + "description": "Ordered list of decisions the client may present for this prompt.\n\nWhen absent, clients should derive the legacy default set from the other fields on this request.", + "items": { + "$ref": "#/definitions/ReviewDecision" + }, + "type": [ + "array", + "null" + ] + }, + "call_id": { + "description": "Identifier for the associated command execution item.", + "type": "string" + }, + "command": { + "description": "The command to be executed.", + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory.", + "type": "string" + }, + "network_approval_context": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional network context for a blocked request that can be approved." + }, + "parsed_cmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "proposed_execpolicy_amendment": { + "description": "Proposed execpolicy amendment that can be applied to allow future runs.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "proposed_network_policy_amendments": { + "description": "Proposed network policy amendments (for example allow/deny this host in future).", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": [ + "array", + "null" + ] + }, + "reason": { + "description": "Optional human-readable reason for the approval (e.g. retry without sandbox).", + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this command belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "exec_approval_request" + ], + "title": "ExecApprovalRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "command", + "cwd", + "parsed_cmd", + "type" + ], + "title": "ExecApprovalRequestEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated tool call, if available.", + "type": "string" + }, + "questions": { + "items": { + "$ref": "#/definitions/RequestUserInputQuestion" + }, + "type": "array" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this request belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "request_user_input" + ], + "title": "RequestUserInputEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "questions", + "type" + ], + "title": "RequestUserInputEventMsg", + "type": "object" + }, + { + "properties": { + "arguments": true, + "callId": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "type": { + "enum": [ + "dynamic_tool_call_request" + ], + "title": "DynamicToolCallRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "arguments", + "callId", + "tool", + "turnId", + "type" + ], + "title": "DynamicToolCallRequestEventMsg", + "type": "object" + }, + { + "properties": { + "arguments": { + "description": "Dynamic tool call arguments." + }, + "call_id": { + "description": "Identifier for the corresponding DynamicToolCallRequest.", + "type": "string" + }, + "content_items": { + "description": "Dynamic tool response content items.", + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": "array" + }, + "duration": { + "allOf": [ + { + "$ref": "#/definitions/Duration" + } + ], + "description": "The duration of the dynamic tool call." + }, + "error": { + "description": "Optional error text when the tool call failed before producing a response.", + "type": [ + "string", + "null" + ] + }, + "success": { + "description": "Whether the tool call succeeded.", + "type": "boolean" + }, + "tool": { + "description": "Dynamic tool name.", + "type": "string" + }, + "turn_id": { + "description": "Turn ID that this dynamic tool call belongs to.", + "type": "string" + }, + "type": { + "enum": [ + "dynamic_tool_call_response" + ], + "title": "DynamicToolCallResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "arguments", + "call_id", + "content_items", + "duration", + "success", + "tool", + "turn_id", + "type" + ], + "title": "DynamicToolCallResponseEventMsg", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "message": { + "type": "string" + }, + "server_name": { + "type": "string" + }, + "type": { + "enum": [ + "elicitation_request" + ], + "title": "ElicitationRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "message", + "server_name", + "type" + ], + "title": "ElicitationRequestEventMsg", + "type": "object" + }, + { + "properties": { + "call_id": { + "description": "Responses API call id for the associated patch apply call, if available.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "type": "object" + }, + "grant_root": { + "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session.", + "type": [ + "string", + "null" + ] + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", + "type": [ + "string", + "null" + ] + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility with older senders.", + "type": "string" + }, + "type": { + "enum": [ + "apply_patch_approval_request" + ], + "title": "ApplyPatchApprovalRequestEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "changes", + "type" + ], + "title": "ApplyPatchApprovalRequestEventMsg", + "type": "object" + }, + { + "description": "Notification advising the user that something they are using has been deprecated and should be phased out.", + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + }, + "type": { + "enum": [ + "deprecation_notice" + ], + "title": "DeprecationNoticeEventMsgType", + "type": "string" + } + }, + "required": [ + "summary", + "type" + ], + "title": "DeprecationNoticeEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": "string" + }, + "type": { + "enum": [ + "background_event" + ], + "title": "BackgroundEventEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "BackgroundEventEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "undo_started" + ], + "title": "UndoStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UndoStartedEventMsg", + "type": "object" + }, + { + "properties": { + "message": { + "type": [ + "string", + "null" + ] + }, + "success": { + "type": "boolean" + }, + "type": { + "enum": [ + "undo_completed" + ], + "title": "UndoCompletedEventMsgType", + "type": "string" + } + }, + "required": [ + "success", + "type" + ], + "title": "UndoCompletedEventMsg", + "type": "object" + }, + { + "description": "Notification that a model stream experienced an error or disconnect and the system is handling it (e.g., retrying with backoff).", + "properties": { + "additional_details": { + "default": null, + "description": "Optional details about the underlying stream failure (often the same human-readable message that is surfaced as the terminal error if retries are exhausted).", + "type": [ + "string", + "null" + ] + }, + "codex_error_info": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ], + "default": null + }, + "message": { + "type": "string" + }, + "type": { + "enum": [ + "stream_error" + ], + "title": "StreamErrorEventMsgType", + "type": "string" + } + }, + "required": [ + "message", + "type" + ], + "title": "StreamErrorEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent is about to apply a code patch. Mirrors `ExecCommandBegin` so front‑ends can show progress indicators.", + "properties": { + "auto_approved": { + "description": "If true, there was no ApplyPatchApprovalRequest for this patch.", + "type": "boolean" + }, + "call_id": { + "description": "Identifier so this can be paired with the PatchApplyEnd event.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "description": "The changes to be applied.", + "type": "object" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "patch_apply_begin" + ], + "title": "PatchApplyBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "auto_approved", + "call_id", + "changes", + "type" + ], + "title": "PatchApplyBeginEventMsg", + "type": "object" + }, + { + "description": "Notification that a patch application has finished.", + "properties": { + "call_id": { + "description": "Identifier for the PatchApplyBegin that finished.", + "type": "string" + }, + "changes": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "default": {}, + "description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).", + "type": "object" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/PatchApplyStatus" + } + ], + "description": "Completion status for this patch application." + }, + "stderr": { + "description": "Captured stderr (parser errors, IO failures, etc.).", + "type": "string" + }, + "stdout": { + "description": "Captured stdout (summary printed by apply_patch).", + "type": "string" + }, + "success": { + "description": "Whether the patch was applied successfully.", + "type": "boolean" + }, + "turn_id": { + "default": "", + "description": "Turn ID that this patch belongs to. Uses `#[serde(default)]` for backwards compatibility.", + "type": "string" + }, + "type": { + "enum": [ + "patch_apply_end" + ], + "title": "PatchApplyEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "status", + "stderr", + "stdout", + "success", + "type" + ], + "title": "PatchApplyEndEventMsg", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "turn_diff" + ], + "title": "TurnDiffEventMsgType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": [ + "type", + "unified_diff" + ], + "title": "TurnDiffEventMsg", + "type": "object" + }, + { + "description": "Response to GetHistoryEntryRequest.", + "properties": { + "entry": { + "anyOf": [ + { + "$ref": "#/definitions/HistoryEntry" + }, + { + "type": "null" + } + ], + "description": "The entry at the requested offset, if available and parseable." + }, + "log_id": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "offset": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": [ + "get_history_entry_response" + ], + "title": "GetHistoryEntryResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "log_id", + "offset", + "type" + ], + "title": "GetHistoryEntryResponseEventMsg", + "type": "object" + }, + { + "description": "List of MCP tools available to the agent.", + "properties": { + "auth_statuses": { + "additionalProperties": { + "$ref": "#/definitions/McpAuthStatus" + }, + "description": "Authentication status for each configured MCP server.", + "type": "object" + }, + "resource_templates": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + }, + "description": "Known resource templates grouped by server name.", + "type": "object" + }, + "resources": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + }, + "description": "Known resources grouped by server name.", + "type": "object" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/Tool" + }, + "description": "Fully qualified tool name -> tool definition.", + "type": "object" + }, + "type": { + "enum": [ + "mcp_list_tools_response" + ], + "title": "McpListToolsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "auth_statuses", + "resource_templates", + "resources", + "tools", + "type" + ], + "title": "McpListToolsResponseEventMsg", + "type": "object" + }, + { + "description": "List of custom prompts available to the agent.", + "properties": { + "custom_prompts": { + "items": { + "$ref": "#/definitions/CustomPrompt" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_custom_prompts_response" + ], + "title": "ListCustomPromptsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "custom_prompts", + "type" + ], + "title": "ListCustomPromptsResponseEventMsg", + "type": "object" + }, + { + "description": "List of skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/SkillsListEntry" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_skills_response" + ], + "title": "ListSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "List of remote skills available to the agent.", + "properties": { + "skills": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "list_remote_skills_response" + ], + "title": "ListRemoteSkillsResponseEventMsgType", + "type": "string" + } + }, + "required": [ + "skills", + "type" + ], + "title": "ListRemoteSkillsResponseEventMsg", + "type": "object" + }, + { + "description": "Remote skill downloaded to local cache.", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "remote_skill_downloaded" + ], + "title": "RemoteSkillDownloadedEventMsgType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "path", + "type" + ], + "title": "RemoteSkillDownloadedEventMsg", + "type": "object" + }, + { + "description": "Notification that skill data may have been updated and clients may want to reload.", + "properties": { + "type": { + "enum": [ + "skills_update_available" + ], + "title": "SkillsUpdateAvailableEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SkillsUpdateAvailableEventMsg", + "type": "object" + }, + { + "properties": { + "explanation": { + "default": null, + "description": "Arguments for the `update_plan` todo/checklist tool (not plan mode).", + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/PlanItemArg" + }, + "type": "array" + }, + "type": { + "enum": [ + "plan_update" + ], + "title": "PlanUpdateEventMsgType", + "type": "string" + } + }, + "required": [ + "plan", + "type" + ], + "title": "PlanUpdateEventMsg", + "type": "object" + }, + { + "properties": { + "reason": { + "$ref": "#/definitions/TurnAbortReason" + }, + "turn_id": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "turn_aborted" + ], + "title": "TurnAbortedEventMsgType", + "type": "string" + } + }, + "required": [ + "reason", + "type" + ], + "title": "TurnAbortedEventMsg", + "type": "object" + }, + { + "description": "Notification that the agent is shutting down.", + "properties": { + "type": { + "enum": [ + "shutdown_complete" + ], + "title": "ShutdownCompleteEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ShutdownCompleteEventMsg", + "type": "object" + }, + { + "description": "Entered review mode.", + "properties": { + "target": { + "$ref": "#/definitions/ReviewTarget" + }, + "type": { + "enum": [ + "entered_review_mode" + ], + "title": "EnteredReviewModeEventMsgType", + "type": "string" + }, + "user_facing_hint": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "target", + "type" + ], + "title": "EnteredReviewModeEventMsg", + "type": "object" + }, + { + "description": "Exited review mode with an optional final result to apply.", + "properties": { + "review_output": { + "anyOf": [ + { + "$ref": "#/definitions/ReviewOutputEvent" + }, + { + "type": "null" + } + ] + }, + "type": { + "enum": [ + "exited_review_mode" + ], + "title": "ExitedReviewModeEventMsgType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExitedReviewModeEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/ResponseItem" + }, + "type": { + "enum": [ + "raw_response_item" + ], + "title": "RawResponseItemEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "type" + ], + "title": "RawResponseItemEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/TurnItem" + }, + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "item_started" + ], + "title": "ItemStartedEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "thread_id", + "turn_id", + "type" + ], + "title": "ItemStartedEventMsg", + "type": "object" + }, + { + "properties": { + "item": { + "$ref": "#/definitions/TurnItem" + }, + "thread_id": { + "$ref": "#/definitions/ThreadId" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "item_completed" + ], + "title": "ItemCompletedEventMsgType", + "type": "string" + } + }, + "required": [ + "item", + "thread_id", + "turn_id", + "type" + ], + "title": "ItemCompletedEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "agent_message_content_delta" + ], + "title": "AgentMessageContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "AgentMessageContentDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "plan_delta" + ], + "title": "PlanDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "PlanDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "summary_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_content_delta" + ], + "title": "ReasoningContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "ReasoningContentDeltaEventMsg", + "type": "object" + }, + { + "properties": { + "content_index": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "thread_id": { + "type": "string" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_raw_content_delta" + ], + "title": "ReasoningRawContentDeltaEventMsgType", + "type": "string" + } + }, + "required": [ + "delta", + "item_id", + "thread_id", + "turn_id", + "type" + ], + "title": "ReasoningRawContentDeltaEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent spawn begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_agent_spawn_begin" + ], + "title": "CollabAgentSpawnBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "sender_thread_id", + "type" + ], + "title": "CollabAgentSpawnBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent spawn end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "new_agent_nickname": { + "description": "Optional nickname assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_agent_role": { + "description": "Optional role assigned to the new agent.", + "type": [ + "string", + "null" + ] + }, + "new_thread_id": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadId" + }, + { + "type": "null" + } + ], + "description": "Thread ID of the newly spawned agent, if it was created." + }, + "prompt": { + "description": "Initial prompt sent to the agent. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the new agent reported to the sender agent." + }, + "type": { + "enum": [ + "collab_agent_spawn_end" + ], + "title": "CollabAgentSpawnEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabAgentSpawnEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent interaction begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_agent_interaction_begin" + ], + "title": "CollabAgentInteractionBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabAgentInteractionBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: agent interaction end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt sent from the sender to the receiver. Can be empty to prevent CoT leaking at the beginning.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent." + }, + "type": { + "enum": [ + "collab_agent_interaction_end" + ], + "title": "CollabAgentInteractionEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "prompt", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabAgentInteractionEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: waiting begin.", + "properties": { + "call_id": { + "description": "ID of the waiting call.", + "type": "string" + }, + "receiver_agents": { + "description": "Optional nicknames/roles for receivers.", + "items": { + "$ref": "#/definitions/CollabAgentRef" + }, + "type": "array" + }, + "receiver_thread_ids": { + "description": "Thread ID of the receivers.", + "items": { + "$ref": "#/definitions/ThreadId" + }, + "type": "array" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_waiting_begin" + ], + "title": "CollabWaitingBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_ids", + "sender_thread_id", + "type" + ], + "title": "CollabWaitingBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: waiting end.", + "properties": { + "agent_statuses": { + "description": "Optional receiver metadata paired with final statuses.", + "items": { + "$ref": "#/definitions/CollabAgentStatusEntry" + }, + "type": "array" + }, + "call_id": { + "description": "ID of the waiting call.", + "type": "string" + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "statuses": { + "additionalProperties": { + "$ref": "#/definitions/AgentStatus" + }, + "description": "Last known status of the receiver agents reported to the sender agent.", + "type": "object" + }, + "type": { + "enum": [ + "collab_waiting_end" + ], + "title": "CollabWaitingEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "sender_thread_id", + "statuses", + "type" + ], + "title": "CollabWaitingEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: close begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_close_begin" + ], + "title": "CollabCloseBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabCloseBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: close end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent before the close." + }, + "type": { + "enum": [ + "collab_close_end" + ], + "title": "CollabCloseEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabCloseEndEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: resume begin.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "type": { + "enum": [ + "collab_resume_begin" + ], + "title": "CollabResumeBeginEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "type" + ], + "title": "CollabResumeBeginEventMsg", + "type": "object" + }, + { + "description": "Collab interaction: resume end.", + "properties": { + "call_id": { + "description": "Identifier for the collab tool call.", + "type": "string" + }, + "receiver_agent_nickname": { + "description": "Optional nickname assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_agent_role": { + "description": "Optional role assigned to the receiver agent.", + "type": [ + "string", + "null" + ] + }, + "receiver_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the receiver." + }, + "sender_thread_id": { + "allOf": [ + { + "$ref": "#/definitions/ThreadId" + } + ], + "description": "Thread ID of the sender." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/AgentStatus" + } + ], + "description": "Last known status of the receiver agent reported to the sender agent after resume." + }, + "type": { + "enum": [ + "collab_resume_end" + ], + "title": "CollabResumeEndEventMsgType", + "type": "string" + } + }, + "required": [ + "call_id", + "receiver_thread_id", + "sender_thread_id", + "status", + "type" + ], + "title": "CollabResumeEndEventMsg", + "type": "object" + } + ], + "title": "EventMsg" + }, + "ExecCommandSource": { + "enum": [ + "agent", + "user_shell", + "unified_exec_startup", + "unified_exec_interaction" + ], + "type": "string" + }, + "ExecCommandStatus": { + "enum": [ + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "ExecOutputStream": { + "enum": [ + "stdout", + "stderr" + ], + "type": "string" + }, + "ExperimentalFeature": { + "properties": { + "announcement": { + "description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "defaultEnabled": { + "description": "Whether this feature is enabled by default.", + "type": "boolean" + }, + "description": { + "description": "Short summary describing what the feature does. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.", + "type": [ + "string", + "null" + ] + }, + "enabled": { + "description": "Whether this feature is currently enabled in the loaded config.", + "type": "boolean" + }, + "name": { + "description": "Stable key used in config.toml and CLI flag toggles.", + "type": "string" + }, + "stage": { + "allOf": [ + { + "$ref": "#/definitions/ExperimentalFeatureStage" + } + ], + "description": "Lifecycle stage of this feature flag." + } + }, + "required": [ + "defaultEnabled", + "enabled", + "name", + "stage" + ], + "type": "object" + }, + "ExperimentalFeatureListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ExperimentalFeatureListParams", + "type": "object" + }, + "ExperimentalFeatureListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/ExperimentalFeature" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ExperimentalFeatureListResponse", + "type": "object" + }, + "ExperimentalFeatureStage": { + "oneOf": [ + { + "description": "Feature is available for user testing and feedback.", + "enum": [ + "beta" + ], + "type": "string" + }, + { + "description": "Feature is still being built and not ready for broad use.", + "enum": [ + "underDevelopment" + ], + "type": "string" + }, + { + "description": "Feature is production-ready.", + "enum": [ + "stable" + ], + "type": "string" + }, + { + "description": "Feature is deprecated and should be avoided.", + "enum": [ + "deprecated" + ], + "type": "string" + }, + { + "description": "Feature flag is retained only for backwards compatibility.", + "enum": [ + "removed" + ], + "type": "string" + } + ] + }, + "ExternalAgentConfigDetectParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Zero or more working directories to include for repo-scoped detection.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeHome": { + "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "type": "boolean" + } + }, + "title": "ExternalAgentConfigDetectParams", + "type": "object" + }, + "ExternalAgentConfigDetectResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "items": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "title": "ExternalAgentConfigDetectResponse", + "type": "object" + }, + "ExternalAgentConfigImportParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "migrationItems": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItem" + }, + "type": "array" + } + }, + "required": [ + "migrationItems" + ], + "title": "ExternalAgentConfigImportParams", + "type": "object" + }, + "ExternalAgentConfigImportResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ExternalAgentConfigImportResponse", + "type": "object" + }, + "ExternalAgentConfigMigrationItem": { + "properties": { + "cwd": { + "description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.", + "type": [ + "string", + "null" + ] + }, + "description": { + "type": "string" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + } + }, + "required": [ + "description", + "itemType" + ], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "MCP_SERVER_CONFIG" + ], + "type": "string" + }, + "FeedbackUploadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "classification": { + "type": "string" + }, + "extraLogFiles": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "includeLogs": { + "type": "boolean" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "classification", + "includeLogs" + ], + "title": "FeedbackUploadParams", + "type": "object" + }, + "FeedbackUploadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "FeedbackUploadResponse", + "type": "object" + }, + "FileChange": { + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": [ + "add" + ], + "title": "AddFileChangeType", + "type": "string" + } + }, + "required": [ + "content", + "type" + ], + "title": "AddFileChange", + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": [ + "delete" + ], + "title": "DeleteFileChangeType", + "type": "string" + } + }, + "required": [ + "content", + "type" + ], + "title": "DeleteFileChange", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdateFileChangeType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": [ + "type", + "unified_diff" + ], + "title": "UpdateFileChange", + "type": "object" + } + ] + }, + "FileChangeOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "FileChangeOutputDeltaNotification", + "type": "object" + }, + "FileSystemPermissions": { + "properties": { + "read": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "write": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": [ + "diff", + "kind", + "path" + ], + "type": "object" + }, + "ForcedLoginMethod": { + "enum": [ + "chatgpt", + "api" + ], + "type": "string" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "input_text" + ], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "image_url": { + "type": "string" + }, + "type": { + "enum": [ + "input_image" + ], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "image_url", + "type" + ], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "FunctionCallOutputPayload": { + "description": "The payload we send back to OpenAI when reporting a tool call result.\n\n`body` serializes directly as the wire value for `function_call_output.output`. `success` remains internal metadata for downstream handling.", + "properties": { + "body": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "success": { + "type": [ + "boolean", + "null" + ] + } + }, + "required": [ + "body" + ], + "type": "object" + }, + "FuzzyFileSearchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cancellationToken": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": "string" + }, + "roots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "query", + "roots" + ], + "title": "FuzzyFileSearchParams", + "type": "object" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": [ + "array", + "null" + ] + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "file_name", + "path", + "root", + "score" + ], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "title": "FuzzyFileSearchSessionCompletedNotification", + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "files", + "query", + "sessionId" + ], + "title": "FuzzyFileSearchSessionUpdatedNotification", + "type": "object" + }, + "GetAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "refreshToken": { + "default": false, + "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", + "type": "boolean" + } + }, + "title": "GetAccountParams", + "type": "object" + }, + "GetAccountRateLimitsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "rateLimits": { + "allOf": [ + { + "$ref": "#/definitions/RateLimitSnapshot" + } + ], + "description": "Backward-compatible single-bucket view; mirrors the historical payload." + }, + "rateLimitsByLimitId": { + "additionalProperties": { + "$ref": "#/definitions/RateLimitSnapshot" + }, + "description": "Multi-bucket view keyed by metered `limit_id` (for example, `codex`).", + "type": [ + "object", + "null" + ] + } + }, + "required": [ + "rateLimits" + ], + "title": "GetAccountRateLimitsResponse", + "type": "object" + }, + "GetAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "account": { + "anyOf": [ + { + "$ref": "#/definitions/Account" + }, + { + "type": "null" + } + ] + }, + "requiresOpenaiAuth": { + "type": "boolean" + } + }, + "required": [ + "requiresOpenaiAuth" + ], + "title": "GetAccountResponse", + "type": "object" + }, + "GhostCommit": { + "description": "Details of a ghost commit created from a repository state.", + "properties": { + "id": { + "type": "string" + }, + "parent": { + "type": [ + "string", + "null" + ] + }, + "preexisting_untracked_dirs": { + "items": { + "type": "string" + }, + "type": "array" + }, + "preexisting_untracked_files": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "id", + "preexisting_untracked_dirs", + "preexisting_untracked_files" + ], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "originUrl": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "HazelnutScope": { + "enum": [ + "example", + "workspace-shared", + "all-shared", + "personal" + ], + "type": "string" + }, + "HistoryEntry": { + "properties": { + "conversation_id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "ts": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "conversation_id", + "text", + "ts" + ], + "type": "object" + }, + "InitializeCapabilities": { + "description": "Client-declared capabilities negotiated during initialize.", + "properties": { + "experimentalApi": { + "default": false, + "description": "Opt into receiving experimental API methods and fields.", + "type": "boolean" + }, + "optOutNotificationMethods": { + "description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, + "InitializeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/InitializeCapabilities" + }, + { + "type": "null" + } + ] + }, + "clientInfo": { + "$ref": "#/definitions/ClientInfo" + } + }, + "required": [ + "clientInfo" + ], + "title": "InitializeParams", + "type": "object" + }, + "InputModality": { + "description": "Canonical user-input modality tags advertised by a model.", + "oneOf": [ + { + "description": "Plain text turns and tool payloads.", + "enum": [ + "text" + ], + "type": "string" + }, + { + "description": "Image attachments included in user turns.", + "enum": [ + "image" + ], + "type": "string" + } + ] + }, + "ItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "ItemCompletedNotification", + "type": "object" + }, + "ItemStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "ItemStartedNotification", + "type": "object" + }, + "ListMcpServerStatusParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a server-defined value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ListMcpServerStatusParams", + "type": "object" + }, + "ListMcpServerStatusResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/McpServerStatus" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ListMcpServerStatusResponse", + "type": "object" + }, + "LocalShellAction": { + "oneOf": [ + { + "properties": { + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "env": { + "additionalProperties": { + "type": "string" + }, + "type": [ + "object", + "null" + ] + }, + "timeout_ms": { + "format": "uint64", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "exec" + ], + "title": "ExecLocalShellActionType", + "type": "string" + }, + "user": { + "type": [ + "string", + "null" + ] + }, + "working_directory": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "command", + "type" + ], + "title": "ExecLocalShellAction", + "type": "object" + } + ] + }, + "LocalShellStatus": { + "enum": [ + "completed", + "in_progress", + "incomplete" + ], + "type": "string" + }, + "LoginAccountParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "apiKey": { + "type": "string" + }, + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "apiKey", + "type" + ], + "title": "ApiKeyv2::LoginAccountParams", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "Chatgptv2::LoginAccountParams", + "type": "object" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have.", + "properties": { + "accessToken": { + "description": "Access token (JWT) supplied by the client. This token is used for backend API requests and email extraction.", + "type": "string" + }, + "chatgptAccountId": { + "description": "Workspace/account identifier supplied by the client.", + "type": "string" + }, + "chatgptPlanType": { + "description": "Optional plan type supplied by the client.\n\nWhen `null`, Codex attempts to derive the plan type from access-token claims. If unavailable, the plan defaults to `unknown`.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParamsType", + "type": "string" + } + }, + "required": [ + "accessToken", + "chatgptAccountId", + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountParams", + "type": "object" + } + ], + "title": "LoginAccountParams" + }, + "LoginAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "apiKey" + ], + "title": "ApiKeyv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ApiKeyv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "authUrl": { + "description": "URL the client should open in a browser to initiate the OAuth flow.", + "type": "string" + }, + "loginId": { + "type": "string" + }, + "type": { + "enum": [ + "chatgpt" + ], + "title": "Chatgptv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "authUrl", + "loginId", + "type" + ], + "title": "Chatgptv2::LoginAccountResponse", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "chatgptAuthTokens" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponseType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ChatgptAuthTokensv2::LoginAccountResponse", + "type": "object" + } + ], + "title": "LoginAccountResponse" + }, + "LogoutAccountResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "LogoutAccountResponse", + "type": "object" + }, + "MacOsAutomationValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ] + }, + "MacOsPermissions": { + "properties": { + "accessibility": { + "type": [ + "boolean", + "null" + ] + }, + "automations": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsAutomationValue" + }, + { + "type": "null" + } + ] + }, + "calendar": { + "type": [ + "boolean", + "null" + ] + }, + "preferences": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPreferencesValue" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "MacOsPreferencesValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string" + } + ] + }, + "McpAuthStatus": { + "enum": [ + "unsupported", + "notLoggedIn", + "bearerToken", + "oAuth" + ], + "type": "string" + }, + "McpInvocation": { + "properties": { + "arguments": { + "description": "Arguments to the tool call." + }, + "server": { + "description": "Name of the MCP server as defined in the config.", + "type": "string" + }, + "tool": { + "description": "Name of the tool as given by the MCP server.", + "type": "string" + } + }, + "required": [ + "server", + "tool" + ], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "name", + "success" + ], + "title": "McpServerOauthLoginCompletedNotification", + "type": "object" + }, + "McpServerOauthLoginParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "scopes": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "timeoutSecs": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "name" + ], + "title": "McpServerOauthLoginParams", + "type": "object" + }, + "McpServerOauthLoginResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "authorizationUrl": { + "type": "string" + } + }, + "required": [ + "authorizationUrl" + ], + "title": "McpServerOauthLoginResponse", + "type": "object" + }, + "McpServerRefreshResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "McpServerRefreshResponse", + "type": "object" + }, + "McpServerStatus": { + "properties": { + "authStatus": { + "$ref": "#/definitions/McpAuthStatus" + }, + "name": { + "type": "string" + }, + "resourceTemplates": { + "items": { + "$ref": "#/definitions/ResourceTemplate" + }, + "type": "array" + }, + "resources": { + "items": { + "$ref": "#/definitions/Resource" + }, + "type": "array" + }, + "tools": { + "additionalProperties": { + "$ref": "#/definitions/Tool" + }, + "type": "object" + } + }, + "required": [ + "authStatus", + "name", + "resourceTemplates", + "resources", + "tools" + ], + "type": "object" + }, + "McpStartupFailure": { + "properties": { + "error": { + "type": "string" + }, + "server": { + "type": "string" + } + }, + "required": [ + "error", + "server" + ], + "type": "object" + }, + "McpStartupStatus": { + "oneOf": [ + { + "properties": { + "state": { + "enum": [ + "starting" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "StartingMcpStartupStatus", + "type": "object" + }, + { + "properties": { + "state": { + "enum": [ + "ready" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "ReadyMcpStartupStatus", + "type": "object" + }, + { + "properties": { + "error": { + "type": "string" + }, + "state": { + "enum": [ + "failed" + ], + "type": "string" + } + }, + "required": [ + "error", + "state" + ], + "type": "object" + }, + { + "properties": { + "state": { + "enum": [ + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "state" + ], + "title": "CancelledMcpStartupStatus", + "type": "object" + } + ] + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "McpToolCallProgressNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "message", + "threadId", + "turnId" + ], + "title": "McpToolCallProgressNotification", + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": [ + "content" + ], + "type": "object" + }, + "McpToolCallStatus": { + "enum": [ + "inProgress", + "completed", + "failed" + ], + "type": "string" + }, + "MergeStrategy": { + "enum": [ + "replace", + "upsert" + ], + "type": "string" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": [ + "commentary" + ], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": [ + "final_answer" + ], + "type": "string" + } + ] + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "Model": { + "properties": { + "availabilityNux": { + "anyOf": [ + { + "$ref": "#/definitions/ModelAvailabilityNux" + }, + { + "type": "null" + } + ] + }, + "defaultReasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + }, + "description": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "hidden": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "inputModalities": { + "default": [ + "text", + "image" + ], + "items": { + "$ref": "#/definitions/InputModality" + }, + "type": "array" + }, + "isDefault": { + "type": "boolean" + }, + "model": { + "type": "string" + }, + "supportedReasoningEfforts": { + "items": { + "$ref": "#/definitions/ReasoningEffortOption" + }, + "type": "array" + }, + "supportsPersonality": { + "default": false, + "type": "boolean" + }, + "upgrade": { + "type": [ + "string", + "null" + ] + }, + "upgradeInfo": { + "anyOf": [ + { + "$ref": "#/definitions/ModelUpgradeInfo" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "defaultReasoningEffort", + "description", + "displayName", + "hidden", + "id", + "isDefault", + "model", + "supportedReasoningEfforts" + ], + "type": "object" + }, + "ModelAvailabilityNux": { + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "ModelListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "includeHidden": { + "description": "When true, include models that are hidden from the default picker list.", + "type": [ + "boolean", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ModelListParams", + "type": "object" + }, + "ModelListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/Model" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ModelListResponse", + "type": "object" + }, + "ModelRerouteReason": { + "enum": [ + "highRiskCyberActivity" + ], + "type": "string" + }, + "ModelReroutedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "fromModel", + "reason", + "threadId", + "toModel", + "turnId" + ], + "title": "ModelReroutedNotification", + "type": "object" + }, + "ModelUpgradeInfo": { + "properties": { + "migrationMarkdown": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "modelLink": { + "type": [ + "string", + "null" + ] + }, + "upgradeCopy": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": [ + "host", + "protocol" + ], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": [ + "http", + "https", + "socks5Tcp", + "socks5Udp" + ], + "type": "string" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": [ + "action", + "host" + ], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkRequirements": { + "properties": { + "allowLocalBinding": { + "type": [ + "boolean", + "null" + ] + }, + "allowUnixSockets": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "allowUpstreamProxy": { + "type": [ + "boolean", + "null" + ] + }, + "allowedDomains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "dangerouslyAllowAllUnixSockets": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackAdmin": { + "type": [ + "boolean", + "null" + ] + }, + "dangerouslyAllowNonLoopbackProxy": { + "type": [ + "boolean", + "null" + ] + }, + "deniedDomains": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "enabled": { + "type": [ + "boolean", + "null" + ] + }, + "httpPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "socksPort": { + "format": "uint16", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "OverriddenMetadata": { + "properties": { + "effectiveValue": true, + "message": { + "type": "string" + }, + "overridingLayer": { + "$ref": "#/definitions/ConfigLayerMetadata" + } + }, + "required": [ + "effectiveValue", + "message", + "overridingLayer" + ], + "type": "object" + }, + "ParsedCommand": { + "oneOf": [ + { + "properties": { + "cmd": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", + "type": "string" + }, + "type": { + "enum": [ + "read" + ], + "title": "ReadParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "name", + "path", + "type" + ], + "title": "ReadParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "list_files" + ], + "title": "ListFilesParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "ListFilesParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": [ + "string", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "SearchParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "type": { + "enum": [ + "unknown" + ], + "title": "UnknownParsedCommandType", + "type": "string" + } + }, + "required": [ + "cmd", + "type" + ], + "title": "UnknownParsedCommand", + "type": "object" + } + ] + }, + "PatchApplyStatus": { + "enum": [ + "inProgress", + "completed", + "failed", + "declined" + ], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "add" + ], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "delete" + ], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "update" + ], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "PermissionProfile": { + "properties": { + "file_system": { + "anyOf": [ + { + "$ref": "#/definitions/FileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "macos": { + "anyOf": [ + { + "$ref": "#/definitions/MacOsPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "PlanDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "PlanDeltaNotification", + "type": "object" + }, + "PlanItemArg": { + "additionalProperties": false, + "properties": { + "status": { + "$ref": "#/definitions/StepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "team", + "business", + "enterprise", + "edu", + "unknown" + ], + "type": "string" + }, + "ProductSurface": { + "enum": [ + "chatgpt", + "codex", + "api", + "atlas" + ], + "type": "string" + }, + "ProfileV2": { + "additionalProperties": true, + "properties": { + "approval_policy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "chatgpt_base_url": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "model_provider": { + "type": [ + "string", + "null" + ] + }, + "model_reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model_reasoning_summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + }, + "model_verbosity": { + "anyOf": [ + { + "$ref": "#/definitions/Verbosity" + }, + { + "type": "null" + } + ] + }, + "service_tier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "web_search": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchMode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitSnapshot": { + "properties": { + "credits": { + "anyOf": [ + { + "$ref": "#/definitions/CreditsSnapshot" + }, + { + "type": "null" + } + ] + }, + "limitId": { + "type": [ + "string", + "null" + ] + }, + "limitName": { + "type": [ + "string", + "null" + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + }, + "primary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "RateLimitWindow": { + "properties": { + "resetsAt": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "usedPercent": { + "format": "int32", + "type": "integer" + }, + "windowDurationMins": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "usedPercent" + ], + "type": "object" + }, + "RawResponseItemCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "item": { + "$ref": "#/definitions/ResponseItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId", + "turnId" + ], + "title": "RawResponseItemCompletedNotification", + "type": "object" + }, + "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" + } + ] + }, + "RealtimeAudioFrame": { + "properties": { + "data": { + "type": "string" + }, + "num_channels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sample_rate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samples_per_channel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "num_channels", + "sample_rate" + ], + "type": "object" + }, + "RealtimeEvent": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "SessionUpdated": { + "properties": { + "instructions": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + } + }, + "required": [ + "session_id" + ], + "type": "object" + } + }, + "required": [ + "SessionUpdated" + ], + "title": "SessionUpdatedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "AudioOut": { + "$ref": "#/definitions/RealtimeAudioFrame" + } + }, + "required": [ + "AudioOut" + ], + "title": "AudioOutRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemAdded": true + }, + "required": [ + "ConversationItemAdded" + ], + "title": "ConversationItemAddedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ConversationItemDone": { + "properties": { + "item_id": { + "type": "string" + } + }, + "required": [ + "item_id" + ], + "type": "object" + } + }, + "required": [ + "ConversationItemDone" + ], + "title": "ConversationItemDoneRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "HandoffRequested": { + "$ref": "#/definitions/RealtimeHandoffRequested" + } + }, + "required": [ + "HandoffRequested" + ], + "title": "HandoffRequestedRealtimeEvent", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "Error": { + "type": "string" + } + }, + "required": [ + "Error" + ], + "title": "ErrorRealtimeEvent", + "type": "object" + } + ] + }, + "RealtimeHandoffMessage": { + "properties": { + "role": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "role", + "text" + ], + "type": "object" + }, + "RealtimeHandoffRequested": { + "properties": { + "handoff_id": { + "type": "string" + }, + "input_transcript": { + "type": "string" + }, + "item_id": { + "type": "string" + }, + "messages": { + "items": { + "$ref": "#/definitions/RealtimeHandoffMessage" + }, + "type": "array" + } + }, + "required": [ + "handoff_id", + "input_transcript", + "item_id", + "messages" + ], + "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" + }, + "ReasoningEffortOption": { + "properties": { + "description": { + "type": "string" + }, + "reasoningEffort": { + "$ref": "#/definitions/ReasoningEffort" + } + }, + "required": [ + "description", + "reasoningEffort" + ], + "type": "object" + }, + "ReasoningItemContent": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "reasoning_text" + ], + "title": "ReasoningTextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "ReasoningTextReasoningItemContent", + "type": "object" + }, + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextReasoningItemContentType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextReasoningItemContent", + "type": "object" + } + ] + }, + "ReasoningItemReasoningSummary": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": [ + "summary_text" + ], + "title": "SummaryTextReasoningItemReasoningSummaryType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "SummaryTextReasoningItemReasoningSummary", + "type": "object" + } + ] + }, + "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": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryPartAddedNotification", + "type": "object" + }, + "ReasoningSummaryTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "delta", + "itemId", + "summaryIndex", + "threadId", + "turnId" + ], + "title": "ReasoningSummaryTextDeltaNotification", + "type": "object" + }, + "ReasoningTextDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contentIndex": { + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "contentIndex", + "delta", + "itemId", + "threadId", + "turnId" + ], + "title": "ReasoningTextDeltaNotification", + "type": "object" + }, + "RemoteSkillSummary": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestUserInputQuestion": { + "properties": { + "header": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isOther": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "type": "boolean" + }, + "options": { + "items": { + "$ref": "#/definitions/RequestUserInputQuestionOption" + }, + "type": [ + "array", + "null" + ] + }, + "question": { + "type": "string" + } + }, + "required": [ + "header", + "id", + "question" + ], + "type": "object" + }, + "RequestUserInputQuestionOption": { + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "description", + "label" + ], + "type": "object" + }, + "ResidencyRequirement": { + "enum": [ + "us" + ], + "type": "string" + }, + "Resource": { + "description": "A known resource that the server is capable of reading.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "size": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "name", + "uri" + ], + "type": "object" + }, + "ResourceTemplate": { + "description": "A template description for resources available on the server.", + "properties": { + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "mimeType": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "uriTemplate": { + "type": "string" + } + }, + "required": [ + "name", + "uriTemplate" + ], + "type": "object" + }, + "ResponseItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/ContentItem" + }, + "type": "array" + }, + "end_turn": { + "type": [ + "boolean", + "null" + ] + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "type": { + "enum": [ + "message" + ], + "title": "MessageResponseItemType", + "type": "string" + } + }, + "required": [ + "content", + "role", + "type" + ], + "title": "MessageResponseItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": null, + "items": { + "$ref": "#/definitions/ReasoningItemContent" + }, + "type": [ + "array", + "null" + ] + }, + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "writeOnly": true + }, + "summary": { + "items": { + "$ref": "#/definitions/ReasoningItemReasoningSummary" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningResponseItemType", + "type": "string" + } + }, + "required": [ + "id", + "summary", + "type" + ], + "title": "ReasoningResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/LocalShellAction" + }, + "call_id": { + "description": "Set when using the Responses API.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Legacy id field retained for compatibility with older payloads.", + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "$ref": "#/definitions/LocalShellStatus" + }, + "type": { + "enum": [ + "local_shell_call" + ], + "title": "LocalShellCallResponseItemType", + "type": "string" + } + }, + "required": [ + "action", + "status", + "type" + ], + "title": "LocalShellCallResponseItem", + "type": "object" + }, + { + "properties": { + "arguments": { + "type": "string" + }, + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "name": { + "type": "string" + }, + "type": { + "enum": [ + "function_call" + ], + "title": "FunctionCallResponseItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "call_id", + "name", + "type" + ], + "title": "FunctionCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputPayload" + }, + "type": { + "enum": [ + "function_call_output" + ], + "title": "FunctionCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "FunctionCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "input": { + "type": "string" + }, + "name": { + "type": "string" + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "custom_tool_call" + ], + "title": "CustomToolCallResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "input", + "name", + "type" + ], + "title": "CustomToolCallResponseItem", + "type": "object" + }, + { + "properties": { + "call_id": { + "type": "string" + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputPayload" + }, + "type": { + "enum": [ + "custom_tool_call_output" + ], + "title": "CustomToolCallOutputResponseItemType", + "type": "string" + } + }, + "required": [ + "call_id", + "output", + "type" + ], + "title": "CustomToolCallOutputResponseItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": [ + "string", + "null" + ], + "writeOnly": true + }, + "status": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "web_search_call" + ], + "title": "WebSearchCallResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "WebSearchCallResponseItem", + "type": "object" + }, + { + "properties": { + "ghost_commit": { + "$ref": "#/definitions/GhostCommit" + }, + "type": { + "enum": [ + "ghost_snapshot" + ], + "title": "GhostSnapshotResponseItemType", + "type": "string" + } + }, + "required": [ + "ghost_commit", + "type" + ], + "title": "GhostSnapshotResponseItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "compaction" + ], + "title": "CompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "CompactionResponseItem", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherResponseItem", + "type": "object" + } + ] + }, + "Result_of_CallToolResult_or_String": { + "oneOf": [ + { + "properties": { + "Ok": { + "$ref": "#/definitions/CallToolResult" + } + }, + "required": [ + "Ok" + ], + "title": "OkResult_of_CallToolResult_or_String", + "type": "object" + }, + { + "properties": { + "Err": { + "type": "string" + } + }, + "required": [ + "Err" + ], + "title": "ErrResult_of_CallToolResult_or_String", + "type": "object" + } + ] + }, + "ReviewCodeLocation": { + "description": "Location of the code related to a review finding.", + "properties": { + "absolute_file_path": { + "type": "string" + }, + "line_range": { + "$ref": "#/definitions/ReviewLineRange" + } + }, + "required": [ + "absolute_file_path", + "line_range" + ], + "type": "object" + }, + "ReviewDecision": { + "description": "User's decision in response to an ExecApprovalRequest.", + "oneOf": [ + { + "description": "User has approved this command and the agent should execute it.", + "enum": [ + "approved" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User has approved this command and wants to apply the proposed execpolicy amendment so future matching commands are permitted.", + "properties": { + "approved_execpolicy_amendment": { + "properties": { + "proposed_execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "proposed_execpolicy_amendment" + ], + "type": "object" + } + }, + "required": [ + "approved_execpolicy_amendment" + ], + "title": "ApprovedExecpolicyAmendmentReviewDecision", + "type": "object" + }, + { + "description": "User has approved this request and wants future prompts in the same session-scoped approval cache to be automatically approved for the remainder of the session.", + "enum": [ + "approved_for_session" + ], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User chose to persist a network policy rule (allow/deny) for future requests to the same host.", + "properties": { + "network_policy_amendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": [ + "network_policy_amendment" + ], + "type": "object" + } + }, + "required": [ + "network_policy_amendment" + ], + "title": "NetworkPolicyAmendmentReviewDecision", + "type": "object" + }, + { + "description": "User has denied this command and the agent should not execute it, but it should continue the session and try something else.", + "enum": [ + "denied" + ], + "type": "string" + }, + { + "description": "User has denied this command and the agent should not do anything until the user's next command.", + "enum": [ + "abort" + ], + "type": "string" + } + ] + }, + "ReviewDelivery": { + "enum": [ + "inline", + "detached" + ], + "type": "string" + }, + "ReviewFinding": { + "description": "A single review finding describing an observed issue or recommendation.", + "properties": { + "body": { + "type": "string" + }, + "code_location": { + "$ref": "#/definitions/ReviewCodeLocation" + }, + "confidence_score": { + "format": "float", + "type": "number" + }, + "priority": { + "format": "int32", + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "body", + "code_location", + "confidence_score", + "priority", + "title" + ], + "type": "object" + }, + "ReviewLineRange": { + "description": "Inclusive line range in a file associated with the finding.", + "properties": { + "end": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "ReviewOutputEvent": { + "description": "Structured review result produced by a child review session.", + "properties": { + "findings": { + "items": { + "$ref": "#/definitions/ReviewFinding" + }, + "type": "array" + }, + "overall_confidence_score": { + "format": "float", + "type": "number" + }, + "overall_correctness": { + "type": "string" + }, + "overall_explanation": { + "type": "string" + } + }, + "required": [ + "findings", + "overall_confidence_score", + "overall_correctness", + "overall_explanation" + ], + "type": "object" + }, + "ReviewStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/ReviewDelivery" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Where to run the review: inline (default) on the current thread or detached on a new thread (returned in `reviewThreadId`)." + }, + "target": { + "$ref": "#/definitions/ReviewTarget" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "target", + "threadId" + ], + "title": "ReviewStartParams", + "type": "object" + }, + "ReviewStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "reviewThreadId": { + "description": "Identifies the thread where the review runs.\n\nFor inline reviews, this is the original thread id. For detached reviews, this is the id of the new review thread.", + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "reviewThreadId", + "turn" + ], + "title": "ReviewStartResponse", + "type": "object" + }, + "ReviewTarget": { + "oneOf": [ + { + "description": "Review the working tree: staged, unstaged, and untracked files.", + "properties": { + "type": { + "enum": [ + "uncommittedChanges" + ], + "title": "UncommittedChangesReviewTargetType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UncommittedChangesReviewTarget", + "type": "object" + }, + { + "description": "Review changes between the current branch and the given base branch.", + "properties": { + "branch": { + "type": "string" + }, + "type": { + "enum": [ + "baseBranch" + ], + "title": "BaseBranchReviewTargetType", + "type": "string" + } + }, + "required": [ + "branch", + "type" + ], + "title": "BaseBranchReviewTarget", + "type": "object" + }, + { + "description": "Review the changes introduced by a specific commit.", + "properties": { + "sha": { + "type": "string" + }, + "title": { + "description": "Optional human-readable label (e.g., commit subject) for UIs.", + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "commit" + ], + "title": "CommitReviewTargetType", + "type": "string" + } + }, + "required": [ + "sha", + "type" + ], + "title": "CommitReviewTarget", + "type": "object" + }, + { + "description": "Arbitrary instructions, equivalent to the old free-form prompt.", + "properties": { + "instructions": { + "type": "string" + }, + "type": { + "enum": [ + "custom" + ], + "title": "CustomReviewTargetType", + "type": "string" + } + }, + "required": [ + "instructions", + "type" + ], + "title": "CustomReviewTarget", + "type": "object" + } + ] + }, + "SandboxMode": { + "enum": [ + "read-only", + "workspace-write", + "danger-full-access" + ], + "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" + } + }, + "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" + } + ] + }, + "SandboxWorkspaceWrite": { + "properties": { + "exclude_slash_tmp": { + "default": false, + "type": "boolean" + }, + "exclude_tmpdir_env_var": { + "default": false, + "type": "boolean" + }, + "network_access": { + "default": false, + "type": "boolean" + }, + "writable_roots": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "ServerNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", + "properties": { + "method": { + "enum": [ + "error" + ], + "title": "ErrorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ErrorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/started" + ], + "title": "Thread/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/status/changed" + ], + "title": "Thread/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStatusChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/archived" + ], + "title": "Thread/archivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/archivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/unarchived" + ], + "title": "Thread/unarchivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchivedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/unarchivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/closed" + ], + "title": "Thread/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/closedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/name/updated" + ], + "title": "Thread/name/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadNameUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/name/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/tokenUsage/updated" + ], + "title": "Thread/tokenUsage/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/tokenUsage/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/started" + ], + "title": "Turn/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/completed" + ], + "title": "Turn/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/diff/updated" + ], + "title": "Turn/diff/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnDiffUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/diff/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "turn/plan/updated" + ], + "title": "Turn/plan/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnPlanUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Turn/plan/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/started" + ], + "title": "Item/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/completed" + ], + "title": "Item/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/agentMessage/delta" + ], + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AgentMessageDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/agentMessage/deltaNotification", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "properties": { + "method": { + "enum": [ + "item/plan/delta" + ], + "title": "Item/plan/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PlanDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/outputDelta" + ], + "title": "Item/commandExecution/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/commandExecution/terminalInteraction" + ], + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TerminalInteractionNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/fileChange/outputDelta" + ], + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "serverRequest/resolved" + ], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ServerRequestResolvedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ServerRequest/resolvedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/mcpToolCall/progress" + ], + "title": "Item/mcpToolCall/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpToolCallProgressNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/mcpToolCall/progressNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "mcpServer/oauthLogin/completed" + ], + "title": "McpServer/oauthLogin/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "McpServer/oauthLogin/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/updated" + ], + "title": "Account/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/rateLimits/updated" + ], + "title": "Account/rateLimits/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/rateLimits/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "app/list/updated" + ], + "title": "App/list/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppListUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "App/list/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryTextDelta" + ], + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryTextDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/summaryPartAdded" + ], + "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/summaryPartAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "item/reasoning/textDelta" + ], + "title": "Item/reasoning/textDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningTextDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Item/reasoning/textDeltaNotification", + "type": "object" + }, + { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "method": { + "enum": [ + "thread/compacted" + ], + "title": "Thread/compactedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ContextCompactedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/compactedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "model/rerouted" + ], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/reroutedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "deprecationNotice" + ], + "title": "DeprecationNoticeNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/DeprecationNoticeNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "configWarning" + ], + "title": "ConfigWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "ConfigWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionUpdated" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "fuzzyFileSearch/sessionCompleted" + ], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/started" + ], + "title": "Thread/realtime/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeStartedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/itemAdded" + ], + "title": "Thread/realtime/itemAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemAddedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/itemAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/outputAudio/delta" + ], + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeOutputAudioDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/outputAudio/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/error" + ], + "title": "Thread/realtime/errorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeErrorNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/errorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/realtime/closed" + ], + "title": "Thread/realtime/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeClosedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/realtime/closedNotification", + "type": "object" + }, + { + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + "properties": { + "method": { + "enum": [ + "windows/worldWritableWarning" + ], + "title": "Windows/worldWritableWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsWorldWritableWarningNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Windows/worldWritableWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "windowsSandbox/setupCompleted" + ], + "title": "WindowsSandbox/setupCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "WindowsSandbox/setupCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "account/login/completed" + ], + "title": "Account/login/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountLoginCompletedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Account/login/completedNotification", + "type": "object" + } + ], + "title": "ServerNotification" + }, + "ServerRequestResolvedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "requestId", + "threadId" + ], + "title": "ServerRequestResolvedNotification", + "type": "object" + }, + "ServiceTier": { + "enum": [ + "fast" + ], + "type": "string" + }, + "SessionNetworkProxyRuntime": { + "properties": { + "admin_addr": { + "type": "string" + }, + "http_addr": { + "type": "string" + }, + "socks_addr": { + "type": "string" + } + }, + "required": [ + "admin_addr", + "http_addr", + "socks_addr" + ], + "type": "object" + }, + "SessionSource": { + "oneOf": [ + { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": [ + "subAgent" + ], + "title": "SubAgentSessionSource", + "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" + }, + "SkillDependencies": { + "properties": { + "tools": { + "items": { + "$ref": "#/definitions/SkillToolDependency" + }, + "type": "array" + } + }, + "required": [ + "tools" + ], + "type": "object" + }, + "SkillErrorInfo": { + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "message", + "path" + ], + "type": "object" + }, + "SkillInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "iconLarge": { + "type": [ + "string", + "null" + ] + }, + "iconSmall": { + "type": [ + "string", + "null" + ] + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "SkillMetadata": { + "properties": { + "dependencies": { + "anyOf": [ + { + "$ref": "#/definitions/SkillDependencies" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/SkillInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/SkillScope" + }, + "shortDescription": { + "description": "Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "description", + "enabled", + "name", + "path", + "scope" + ], + "type": "object" + }, + "SkillScope": { + "enum": [ + "user", + "repo", + "system", + "admin" + ], + "type": "string" + }, + "SkillToolDependency": { + "properties": { + "command": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "transport": { + "type": [ + "string", + "null" + ] + }, + "type": { + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "SkillsConfigWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "required": [ + "enabled", + "path" + ], + "title": "SkillsConfigWriteParams", + "type": "object" + }, + "SkillsConfigWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "effectiveEnabled": { + "type": "boolean" + } + }, + "required": [ + "effectiveEnabled" + ], + "title": "SkillsConfigWriteResponse", + "type": "object" + }, + "SkillsListEntry": { + "properties": { + "cwd": { + "type": "string" + }, + "errors": { + "items": { + "$ref": "#/definitions/SkillErrorInfo" + }, + "type": "array" + }, + "skills": { + "items": { + "$ref": "#/definitions/SkillMetadata" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "errors", + "skills" + ], + "type": "object" + }, + "SkillsListExtraRootsForCwd": { + "properties": { + "cwd": { + "type": "string" + }, + "extraUserRoots": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "cwd", + "extraUserRoots" + ], + "type": "object" + }, + "SkillsListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "When empty, defaults to the current session working directory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "forceReload": { + "description": "When true, bypass the skills cache and re-scan skills from disk.", + "type": "boolean" + }, + "perCwdExtraUserRoots": { + "default": null, + "description": "Optional per-cwd extra roots to scan as user-scoped skills.", + "items": { + "$ref": "#/definitions/SkillsListExtraRootsForCwd" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "SkillsListParams", + "type": "object" + }, + "SkillsListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/SkillsListEntry" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsListResponse", + "type": "object" + }, + "SkillsRemoteReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "hazelnutScope": { + "allOf": [ + { + "$ref": "#/definitions/HazelnutScope" + } + ], + "default": "example" + }, + "productSurface": { + "allOf": [ + { + "$ref": "#/definitions/ProductSurface" + } + ], + "default": "codex" + } + }, + "title": "SkillsRemoteReadParams", + "type": "object" + }, + "SkillsRemoteReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/RemoteSkillSummary" + }, + "type": "array" + } + }, + "required": [ + "data" + ], + "title": "SkillsRemoteReadResponse", + "type": "object" + }, + "SkillsRemoteWriteParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "hazelnutId": { + "type": "string" + } + }, + "required": [ + "hazelnutId" + ], + "title": "SkillsRemoteWriteParams", + "type": "object" + }, + "SkillsRemoteWriteResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "id", + "path" + ], + "title": "SkillsRemoteWriteResponse", + "type": "object" + }, + "StepStatus": { + "enum": [ + "pending", + "in_progress", + "completed" + ], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": [ + "review", + "compact", + "memory_consolidation" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "agent_role": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": [ + "depth", + "parent_thread_id" + ], + "type": "object" + } + }, + "required": [ + "thread_spawn" + ], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TerminalInteractionNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "itemId": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "itemId", + "processId", + "stdin", + "threadId", + "turnId" + ], + "title": "TerminalInteractionNotification", + "type": "object" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "byteRange" + ], + "type": "object" + }, + "TextPosition": { + "properties": { + "column": { + "description": "1-based column number (in Unicode scalar values).", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "line": { + "description": "1-based line number.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "column", + "line" + ], + "type": "object" + }, + "TextRange": { + "properties": { + "end": { + "$ref": "#/definitions/TextPosition" + }, + "start": { + "$ref": "#/definitions/TextPosition" + } + }, + "required": [ + "end", + "start" + ], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": [ + "string", + "null" + ] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "description": "Working directory captured for the thread.", + "type": "string" + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "id": { + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": [ + "string", + "null" + ] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": [ + "string", + "null" + ] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": [ + "waitingOnApproval", + "waitingOnUserInput" + ], + "type": "string" + }, + "ThreadArchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchiveParams", + "type": "object" + }, + "ThreadArchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadArchiveResponse", + "type": "object" + }, + "ThreadArchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadArchivedNotification", + "type": "object" + }, + "ThreadClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadClosedNotification", + "type": "object" + }, + "ThreadCompactStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadCompactStartParams", + "type": "object" + }, + "ThreadCompactStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadCompactStartResponse", + "type": "object" + }, + "ThreadForkParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration overrides for the forked thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadForkParams", + "type": "object" + }, + "ThreadForkResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadForkResponse", + "type": "object" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "userMessage" + ], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "agentMessage" + ], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "plan" + ], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "reasoning" + ], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": [ + "string", + "null" + ] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "description": "The command's working directory.", + "type": "string" + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": [ + "commandExecution" + ], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": [ + "command", + "commandActions", + "cwd", + "id", + "status", + "type" + ], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": [ + "fileChange" + ], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": [ + "changes", + "id", + "status", + "type" + ], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "mcpToolCall" + ], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "server", + "status", + "tool", + "type" + ], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": [ + "array", + "null" + ] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": [ + "boolean", + "null" + ] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": [ + "dynamicToolCall" + ], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "arguments", + "id", + "status", + "tool", + "type" + ], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": [ + "string", + "null" + ] + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": [ + "collabAgentToolCall" + ], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "webSearch" + ], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "query", + "type" + ], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "imageView" + ], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "path", + "type" + ], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "enteredReviewMode" + ], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": [ + "exitedReviewMode" + ], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "review", + "type" + ], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "contextCompaction" + ], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "archived": { + "description": "Optional archived filter; when set to true, only archived threads are returned. If false or null, only non-archived threads are returned.", + "type": [ + "boolean", + "null" + ] + }, + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional cwd filter; when set, only threads whose session cwd exactly matches this path are returned.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to a reasonable server-side value.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "modelProviders": { + "description": "Optional provider filter; when set, only sessions recorded under these providers are returned. When present but empty, includes all providers.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "searchTerm": { + "description": "Optional substring filter for the extracted thread title.", + "type": [ + "string", + "null" + ] + }, + "sortKey": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSortKey" + }, + { + "type": "null" + } + ], + "description": "Optional sort key; defaults to created_at." + }, + "sourceKinds": { + "description": "Optional source filter; when set, only sessions from these source kinds are returned. When omitted or empty, defaults to interactive sources.", + "items": { + "$ref": "#/definitions/ThreadSourceKind" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "ThreadListParams", + "type": "object" + }, + "ThreadListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/Thread" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadListResponse", + "type": "object" + }, + "ThreadLoadedListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to no limit.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "ThreadLoadedListParams", + "type": "object" + }, + "ThreadLoadedListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "description": "Thread ids for sessions currently loaded in memory.", + "items": { + "type": "string" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. if None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "ThreadLoadedListResponse", + "type": "object" + }, + "ThreadNameUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadNameUpdatedNotification", + "type": "object" + }, + "ThreadReadParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "includeTurns": { + "default": false, + "description": "When true, include turns and their items from rollout history.", + "type": "boolean" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadReadParams", + "type": "object" + }, + "ThreadReadResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadReadResponse", + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "data", + "numChannels", + "sampleRate" + ], + "type": "object" + }, + "ThreadRealtimeClosedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeClosedNotification", + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "message", + "threadId" + ], + "title": "ThreadRealtimeErrorNotification", + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": [ + "item", + "threadId" + ], + "title": "ThreadRealtimeItemAddedNotification", + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "audio", + "threadId" + ], + "title": "ThreadRealtimeOutputAudioDeltaNotification", + "type": "object" + }, + "ThreadRealtimeStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "sessionId": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadRealtimeStartedNotification", + "type": "object" + }, + "ThreadResumeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "description": "Configuration overrides for the resumed thread, if any.", + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadResumeParams", + "type": "object" + }, + "ThreadResumeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadResumeResponse", + "type": "object" + }, + "ThreadRollbackParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "numTurns": { + "description": "The number of turns to drop from the end of the thread. Must be >= 1.\n\nThis only modifies the thread's history and does not revert local file changes that have been made by the agent. Clients are responsible for reverting these changes.", + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "numTurns", + "threadId" + ], + "title": "ThreadRollbackParams", + "type": "object" + }, + "ThreadRollbackResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "allOf": [ + { + "$ref": "#/definitions/Thread" + } + ], + "description": "The updated thread after applying the rollback, with `turns` populated.\n\nThe ThreadItems stored in each Turn are lossy since we explicitly do not persist all agent interactions, such as command executions. This is the same behavior as `thread/resume`." + } + }, + "required": [ + "thread" + ], + "title": "ThreadRollbackResponse", + "type": "object" + }, + "ThreadSetNameParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "name": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "name", + "threadId" + ], + "title": "ThreadSetNameParams", + "type": "object" + }, + "ThreadSetNameResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadSetNameResponse", + "type": "object" + }, + "ThreadSortKey": { + "enum": [ + "created_at", + "updated_at" + ], + "type": "string" + }, + "ThreadSourceKind": { + "enum": [ + "cli", + "vscode", + "exec", + "appServer", + "subAgent", + "subAgentReview", + "subAgentCompact", + "subAgentThreadSpawn", + "subAgentOther", + "unknown" + ], + "type": "string" + }, + "ThreadStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ] + }, + "baseInstructions": { + "type": [ + "string", + "null" + ] + }, + "config": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "cwd": { + "type": [ + "string", + "null" + ] + }, + "developerInstructions": { + "type": [ + "string", + "null" + ] + }, + "ephemeral": { + "type": [ + "boolean", + "null" + ] + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "modelProvider": { + "type": [ + "string", + "null" + ] + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxMode" + }, + { + "type": "null" + } + ] + }, + "serviceName": { + "type": [ + "string", + "null" + ] + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "title": "ThreadStartParams", + "type": "object" + }, + "ThreadStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "cwd": { + "type": "string" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "sandbox": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadStartResponse", + "type": "object" + }, + "ThreadStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadStartedNotification", + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "notLoaded" + ], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "idle" + ], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "systemError" + ], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": [ + "active" + ], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": [ + "activeFlags", + "type" + ], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "ThreadStatusChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "status", + "threadId" + ], + "title": "ThreadStatusChangedNotification", + "type": "object" + }, + "ThreadTokenUsage": { + "properties": { + "last": { + "$ref": "#/definitions/TokenUsageBreakdown" + }, + "modelContextWindow": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "total": { + "$ref": "#/definitions/TokenUsageBreakdown" + } + }, + "required": [ + "last", + "total" + ], + "type": "object" + }, + "ThreadTokenUsageUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "tokenUsage": { + "$ref": "#/definitions/ThreadTokenUsage" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "tokenUsage", + "turnId" + ], + "title": "ThreadTokenUsageUpdatedNotification", + "type": "object" + }, + "ThreadUnarchiveParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchiveParams", + "type": "object" + }, + "ThreadUnarchiveResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "thread" + ], + "title": "ThreadUnarchiveResponse", + "type": "object" + }, + "ThreadUnarchivedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnarchivedNotification", + "type": "object" + }, + "ThreadUnsubscribeParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadUnsubscribeParams", + "type": "object" + }, + "ThreadUnsubscribeResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/ThreadUnsubscribeStatus" + } + }, + "required": [ + "status" + ], + "title": "ThreadUnsubscribeResponse", + "type": "object" + }, + "ThreadUnsubscribeStatus": { + "enum": [ + "notLoaded", + "notSubscribed", + "unsubscribed" + ], + "type": "string" + }, + "TokenUsage": { + "properties": { + "cached_input_tokens": { + "format": "int64", + "type": "integer" + }, + "input_tokens": { + "format": "int64", + "type": "integer" + }, + "output_tokens": { + "format": "int64", + "type": "integer" + }, + "reasoning_output_tokens": { + "format": "int64", + "type": "integer" + }, + "total_tokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cached_input_tokens", + "input_tokens", + "output_tokens", + "reasoning_output_tokens", + "total_tokens" + ], + "type": "object" + }, + "TokenUsageBreakdown": { + "properties": { + "cachedInputTokens": { + "format": "int64", + "type": "integer" + }, + "inputTokens": { + "format": "int64", + "type": "integer" + }, + "outputTokens": { + "format": "int64", + "type": "integer" + }, + "reasoningOutputTokens": { + "format": "int64", + "type": "integer" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens" + ], + "type": "object" + }, + "TokenUsageInfo": { + "properties": { + "last_token_usage": { + "$ref": "#/definitions/TokenUsage" + }, + "model_context_window": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "total_token_usage": { + "$ref": "#/definitions/TokenUsage" + } + }, + "required": [ + "last_token_usage", + "total_token_usage" + ], + "type": "object" + }, + "Tool": { + "description": "Definition for a tool the client can call.", + "properties": { + "_meta": true, + "annotations": true, + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "inputSchema": true, + "name": { + "type": "string" + }, + "outputSchema": true, + "title": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "inputSchema", + "name" + ], + "type": "object" + }, + "ToolsV2": { + "properties": { + "view_image": { + "type": [ + "boolean", + "null" + ] + }, + "web_search": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, + "Turn": { + "properties": { + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "type": "string" + }, + "items": { + "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": [ + "id", + "items", + "status" + ], + "type": "object" + }, + "TurnAbortReason": { + "enum": [ + "interrupted", + "replaced", + "review_ended" + ], + "type": "string" + }, + "TurnCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnCompletedNotification", + "type": "object" + }, + "TurnDiffUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Notification that the turn-level unified diff has changed. Contains the latest aggregated diff across all file changes in the turn.", + "properties": { + "diff": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "diff", + "threadId", + "turnId" + ], + "title": "TurnDiffUpdatedNotification", + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "TurnInterruptParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "threadId", + "turnId" + ], + "title": "TurnInterruptParams", + "type": "object" + }, + "TurnInterruptResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "TurnInterruptResponse", + "type": "object" + }, + "TurnItem": { + "oneOf": [ + { + "properties": { + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": [ + "UserMessage" + ], + "title": "UserMessageTurnItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "UserMessageTurnItem", + "type": "object" + }, + { + "description": "Assistant-authored message payload used in turn-item streams.\n\n`phase` is optional because not all providers/models emit it. Consumers should use it when present, but retain legacy completion semantics when it is `None`.", + "properties": { + "content": { + "items": { + "$ref": "#/definitions/AgentMessageContent" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "description": "Optional phase metadata carried through from `ResponseItem::Message`.\n\nThis is currently used by TUI rendering to distinguish mid-turn commentary from a final answer and avoid status-indicator jitter." + }, + "type": { + "enum": [ + "AgentMessage" + ], + "title": "AgentMessageTurnItemType", + "type": "string" + } + }, + "required": [ + "content", + "id", + "type" + ], + "title": "AgentMessageTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": [ + "Plan" + ], + "title": "PlanTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "text", + "type" + ], + "title": "PlanTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "raw_content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "summary_text": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": [ + "Reasoning" + ], + "title": "ReasoningTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "summary_text", + "type" + ], + "title": "ReasoningTurnItem", + "type": "object" + }, + { + "properties": { + "action": { + "$ref": "#/definitions/WebSearchAction" + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "type": { + "enum": [ + "WebSearch" + ], + "title": "WebSearchTurnItemType", + "type": "string" + } + }, + "required": [ + "action", + "id", + "query", + "type" + ], + "title": "WebSearchTurnItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": [ + "ContextCompaction" + ], + "title": "ContextCompactionTurnItemType", + "type": "string" + } + }, + "required": [ + "id", + "type" + ], + "title": "ContextCompactionTurnItem", + "type": "object" + } + ] + }, + "TurnPlanStep": { + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": [ + "status", + "step" + ], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": [ + "pending", + "inProgress", + "completed" + ], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "explanation": { + "type": [ + "string", + "null" + ] + }, + "plan": { + "items": { + "$ref": "#/definitions/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "plan", + "threadId", + "turnId" + ], + "title": "TurnPlanUpdatedNotification", + "type": "object" + }, + "TurnStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "approvalPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/AskForApproval" + }, + { + "type": "null" + } + ], + "description": "Override the approval policy for this turn and subsequent turns." + }, + "cwd": { + "description": "Override the working directory for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning effort for this turn and subsequent turns." + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "model": { + "description": "Override the model for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] + }, + "outputSchema": { + "description": "Optional JSON Schema used to constrain the final assistant message for this turn." + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ], + "description": "Override the personality for this turn and subsequent turns." + }, + "sandboxPolicy": { + "anyOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + }, + { + "type": "null" + } + ], + "description": "Override the sandbox policy for this turn and subsequent turns." + }, + "serviceTier": { + "anyOf": [ + { + "anyOf": [ + { + "$ref": "#/definitions/ServiceTier" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ], + "description": "Override the service tier for this turn and subsequent turns." + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ], + "description": "Override the reasoning summary for this turn and subsequent turns." + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "input", + "threadId" + ], + "title": "TurnStartParams", + "type": "object" + }, + "TurnStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "turn" + ], + "title": "TurnStartResponse", + "type": "object" + }, + "TurnStartedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": [ + "threadId", + "turn" + ], + "title": "TurnStartedNotification", + "type": "object" + }, + "TurnStatus": { + "enum": [ + "completed", + "interrupted", + "failed", + "inProgress" + ], + "type": "string" + }, + "TurnSteerParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "expectedTurnId": { + "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", + "type": "string" + }, + "input": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "expectedTurnId", + "input", + "threadId" + ], + "title": "TurnSteerParams", + "type": "object" + }, + "TurnSteerResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "turnId": { + "type": "string" + } + }, + "required": [ + "turnId" + ], + "title": "TurnSteerResponse", + "type": "object" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": [ + "text" + ], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": [ + "text", + "type" + ], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "image" + ], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": [ + "localImage" + ], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "skill" + ], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": [ + "mention" + ], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": [ + "name", + "path", + "type" + ], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "Verbosity": { + "description": "Controls output length/detail on GPT-5 models via the Responses API. Serialized with lowercase values to match the OpenAI API.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "search" + ], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "open_page" + ], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "find_in_page" + ], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type" + ], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "other" + ], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + }, + "WebSearchMode": { + "enum": [ + "disabled", + "cached", + "live" + ], + "type": "string" + }, + "WindowsSandboxSetupCompletedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": [ + "mode", + "success" + ], + "title": "WindowsSandboxSetupCompletedNotification", + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" + }, + "WindowsSandboxSetupStartParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + } + }, + "required": [ + "mode" + ], + "title": "WindowsSandboxSetupStartParams", + "type": "object" + }, + "WindowsSandboxSetupStartResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "started": { + "type": "boolean" + } + }, + "required": [ + "started" + ], + "title": "WindowsSandboxSetupStartResponse", + "type": "object" + }, + "WindowsWorldWritableWarningNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "failedScan": { + "type": "boolean" + }, + "samplePaths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "extraCount", + "failedScan", + "samplePaths" + ], + "title": "WindowsWorldWritableWarningNotification", + "type": "object" + }, + "WriteStatus": { + "enum": [ + "ok", + "okOverridden" + ], + "type": "string" + } + }, + "title": "CodexAppServerProtocolV2", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts b/codex-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts deleted file mode 100644 index 6441bed68a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/AddConversationListenerParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type AddConversationListenerParams = { conversationId: ThreadId, experimentalRawEvents: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts b/codex-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts deleted file mode 100644 index f7e34ef658..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/AddConversationSubscriptionResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type AddConversationSubscriptionResponse = { subscriptionId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts deleted file mode 100644 index 61fbcc9fc8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type ArchiveConversationParams = { conversationId: ThreadId, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts b/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts deleted file mode 100644 index 24900592b2..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ArchiveConversationResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ArchiveConversationResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts b/codex-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts deleted file mode 100644 index 17cb442fe0..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/AuthStatusChangeNotification.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AuthMode } from "./AuthMode"; - -/** - * Deprecated notification. Use AccountUpdatedNotification instead. - */ -export type AuthStatusChangeNotification = { authMethod: AuthMode | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts b/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts deleted file mode 100644 index dae8e8c784..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CancelLoginChatGptParams = { loginId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts b/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts deleted file mode 100644 index 004e6f8ea2..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/CancelLoginChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CancelLoginChatGptResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 9707644002..f6df83b6e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -1,26 +1,12 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AddConversationListenerParams } from "./AddConversationListenerParams"; -import type { ArchiveConversationParams } from "./ArchiveConversationParams"; -import type { CancelLoginChatGptParams } from "./CancelLoginChatGptParams"; -import type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams"; -import type { ForkConversationParams } from "./ForkConversationParams"; import type { FuzzyFileSearchParams } from "./FuzzyFileSearchParams"; import type { GetAuthStatusParams } from "./GetAuthStatusParams"; import type { GetConversationSummaryParams } from "./GetConversationSummaryParams"; import type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; import type { InitializeParams } from "./InitializeParams"; -import type { InterruptConversationParams } from "./InterruptConversationParams"; -import type { ListConversationsParams } from "./ListConversationsParams"; -import type { LoginApiKeyParams } from "./LoginApiKeyParams"; -import type { NewConversationParams } from "./NewConversationParams"; -import type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; import type { RequestId } from "./RequestId"; -import type { ResumeConversationParams } from "./ResumeConversationParams"; -import type { SendUserMessageParams } from "./SendUserMessageParams"; -import type { SendUserTurnParams } from "./SendUserTurnParams"; -import type { SetDefaultModelParams } from "./SetDefaultModelParams"; import type { AppsListParams } from "./v2/AppsListParams"; import type { CancelLoginAccountParams } from "./v2/CancelLoginAccountParams"; import type { CommandExecParams } from "./v2/CommandExecParams"; @@ -61,4 +47,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts b/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts deleted file mode 100644 index ca28ad775c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SandboxPolicy } from "./SandboxPolicy"; - -export type ExecOneOffCommandParams = { command: Array, timeoutMs: bigint | null, cwd: string | null, sandboxPolicy: SandboxPolicy | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts b/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts deleted file mode 100644 index ff43ec4ca2..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ExecOneOffCommandResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ExecOneOffCommandResponse = { exitCode: number, stdout: string, stderr: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts deleted file mode 100644 index 4ca548fbff..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ForkConversationParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { NewConversationParams } from "./NewConversationParams"; -import type { ThreadId } from "./ThreadId"; - -export type ForkConversationParams = { path: string | null, conversationId: ThreadId | null, overrides: NewConversationParams | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts b/codex-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts deleted file mode 100644 index 80d6e7947c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ForkConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ThreadId } from "./ThreadId"; - -export type ForkConversationResponse = { conversationId: ThreadId, model: string, initialMessages: Array | null, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts b/codex-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts deleted file mode 100644 index a74aba5da6..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/GetUserAgentResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type GetUserAgentResponse = { userAgent: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts b/codex-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts deleted file mode 100644 index f8dcf2e67c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/GetUserSavedConfigResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { UserSavedConfig } from "./UserSavedConfig"; - -export type GetUserSavedConfigResponse = { config: UserSavedConfig, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InputItem.ts b/codex-rs/app-server-protocol/schema/typescript/InputItem.ts deleted file mode 100644 index 3ac72d31d8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/InputItem.ts +++ /dev/null @@ -1,10 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TextElement } from "./TextElement"; - -export type InputItem = { "type": "text", "data": { text: string, -/** - * UI-defined spans within `text` used to render or persist special elements. - */ -text_elements: Array, } } | { "type": "image", "data": { image_url: string, } } | { "type": "localImage", "data": { path: string, } }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts deleted file mode 100644 index 8db162c97c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/InterruptConversationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ThreadId } from "./ThreadId"; - -export type InterruptConversationParams = { conversationId: ThreadId, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts b/codex-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts deleted file mode 100644 index 375604eef3..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/InterruptConversationResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { TurnAbortReason } from "./TurnAbortReason"; - -export type InterruptConversationResponse = { abortReason: TurnAbortReason, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts b/codex-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts deleted file mode 100644 index 27c9f3172a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ListConversationsParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ListConversationsParams = { pageSize: number | null, cursor: string | null, modelProviders: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts deleted file mode 100644 index 0e26443a5f..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ListConversationsResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ConversationSummary } from "./ConversationSummary"; - -export type ListConversationsResponse = { items: Array, nextCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts b/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts deleted file mode 100644 index 3638553d3e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginApiKeyParams = { apiKey: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts b/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts deleted file mode 100644 index a67347aeb7..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/LoginApiKeyResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginApiKeyResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts b/codex-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts deleted file mode 100644 index 82c07bfa2d..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/LoginChatGptCompleteNotification.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Deprecated in favor of AccountLoginCompletedNotification. - */ -export type LoginChatGptCompleteNotification = { loginId: string, success: boolean, error: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts b/codex-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts deleted file mode 100644 index 4147280117..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/LoginChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LoginChatGptResponse = { loginId: string, authUrl: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts b/codex-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts deleted file mode 100644 index ad5dbd9105..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/LogoutChatGptResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type LogoutChatGptResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts deleted file mode 100644 index e1113c27e2..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/NewConversationParams.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { SandboxMode } from "./SandboxMode"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type NewConversationParams = { model: string | null, modelProvider: string | null, profile: string | null, cwd: string | null, approvalPolicy: AskForApproval | null, sandbox: SandboxMode | null, config: { [key in string]?: JsonValue } | null, baseInstructions: string | null, developerInstructions: string | null, compactPrompt: string | null, includeApplyPatchTool: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts b/codex-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts deleted file mode 100644 index 608c2ac110..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/NewConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ThreadId } from "./ThreadId"; - -export type NewConversationResponse = { conversationId: ThreadId, model: string, reasoningEffort: ReasoningEffort | null, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/Profile.ts b/codex-rs/app-server-protocol/schema/typescript/Profile.ts deleted file mode 100644 index 53d16e4a33..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/Profile.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { Verbosity } from "./Verbosity"; - -export type Profile = { model: string | null, modelProvider: string | null, approvalPolicy: AskForApproval | null, modelReasoningEffort: ReasoningEffort | null, modelReasoningSummary: ReasoningSummary | null, modelVerbosity: Verbosity | null, chatgptBaseUrl: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts b/codex-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts deleted file mode 100644 index e9628b6341..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/RemoveConversationListenerParams.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoveConversationListenerParams = { subscriptionId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts b/codex-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts deleted file mode 100644 index 8053d7e4b4..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/RemoveConversationSubscriptionResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type RemoveConversationSubscriptionResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts b/codex-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts deleted file mode 100644 index f2fe9d47c8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ResumeConversationParams.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { NewConversationParams } from "./NewConversationParams"; -import type { ResponseItem } from "./ResponseItem"; -import type { ThreadId } from "./ThreadId"; - -export type ResumeConversationParams = { path: string | null, conversationId: ThreadId | null, history: Array | null, overrides: NewConversationParams | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts b/codex-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts deleted file mode 100644 index 1af5b68599..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ResumeConversationResponse.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ThreadId } from "./ThreadId"; - -export type ResumeConversationResponse = { conversationId: ThreadId, model: string, initialMessages: Array | null, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SandboxMode.ts b/codex-rs/app-server-protocol/schema/typescript/SandboxMode.ts deleted file mode 100644 index b8cf4326b9..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SandboxMode.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; diff --git a/codex-rs/app-server-protocol/schema/typescript/SandboxSettings.ts b/codex-rs/app-server-protocol/schema/typescript/SandboxSettings.ts deleted file mode 100644 index 94139b0e5d..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SandboxSettings.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "./AbsolutePathBuf"; - -export type SandboxSettings = { writableRoots: Array, networkAccess: boolean | null, excludeTmpdirEnvVar: boolean | null, excludeSlashTmp: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts b/codex-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts deleted file mode 100644 index 6aee538eb0..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SendUserMessageParams.ts +++ /dev/null @@ -1,7 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { InputItem } from "./InputItem"; -import type { ThreadId } from "./ThreadId"; - -export type SendUserMessageParams = { conversationId: ThreadId, items: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts b/codex-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts deleted file mode 100644 index 1a03e043a6..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SendUserMessageResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SendUserMessageResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts b/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts deleted file mode 100644 index 063191649a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnParams.ts +++ /dev/null @@ -1,17 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { InputItem } from "./InputItem"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { SandboxPolicy } from "./SandboxPolicy"; -import type { ServiceTier } from "./ServiceTier"; -import type { ThreadId } from "./ThreadId"; -import type { JsonValue } from "./serde_json/JsonValue"; - -export type SendUserTurnParams = { conversationId: ThreadId, items: Array, cwd: string, approvalPolicy: AskForApproval, sandboxPolicy: SandboxPolicy, model: string, serviceTier?: ServiceTier | null | null, effort: ReasoningEffort | null, summary: ReasoningSummary, -/** - * Optional JSON Schema used to constrain the final assistant message for this turn. - */ -outputSchema: JsonValue | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts b/codex-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts deleted file mode 100644 index cffd0ac398..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SendUserTurnResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SendUserTurnResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 43345d9093..d7f70d3558 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -1,11 +1,8 @@ // 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 { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; import type { FuzzyFileSearchSessionCompletedNotification } from "./FuzzyFileSearchSessionCompletedNotification"; import type { FuzzyFileSearchSessionUpdatedNotification } from "./FuzzyFileSearchSessionUpdatedNotification"; -import type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; -import type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; import type { AccountLoginCompletedNotification } from "./v2/AccountLoginCompletedNotification"; import type { AccountRateLimitsUpdatedNotification } from "./v2/AccountRateLimitsUpdatedNotification"; import type { AccountUpdatedNotification } from "./v2/AccountUpdatedNotification"; @@ -51,4 +48,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": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "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": "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/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "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 } | { "method": "authStatusChange", "params": AuthStatusChangeNotification } | { "method": "loginChatGptComplete", "params": LoginChatGptCompleteNotification } | { "method": "sessionConfigured", "params": SessionConfiguredNotification }; +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": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "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": "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/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "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/SessionConfiguredNotification.ts b/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts deleted file mode 100644 index 0fd5edcb3b..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SessionConfiguredNotification.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { EventMsg } from "./EventMsg"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ServiceTier } from "./ServiceTier"; -import type { ThreadId } from "./ThreadId"; - -export type SessionConfiguredNotification = { sessionId: ThreadId, model: string, serviceTier: ServiceTier | null, reasoningEffort: ReasoningEffort | null, historyLogId: bigint, historyEntryCount: number, initialMessages: Array | null, rolloutPath: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts b/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts deleted file mode 100644 index b9e4e7d901..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ReasoningEffort } from "./ReasoningEffort"; - -export type SetDefaultModelParams = { model: string | null, reasoningEffort: ReasoningEffort | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts b/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts deleted file mode 100644 index 1639601e0c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/SetDefaultModelResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SetDefaultModelResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/TextElement.ts b/codex-rs/app-server-protocol/schema/typescript/TextElement.ts index 8841d00499..3dcd369d82 100644 --- a/codex-rs/app-server-protocol/schema/typescript/TextElement.ts +++ b/codex-rs/app-server-protocol/schema/typescript/TextElement.ts @@ -7,7 +7,7 @@ export type TextElement = { /** * Byte range in the parent `text` buffer that this element occupies. */ -byteRange: ByteRange, +byte_range: ByteRange, /** * Optional human-readable placeholder for the element, displayed in the UI. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/Tools.ts b/codex-rs/app-server-protocol/schema/typescript/Tools.ts deleted file mode 100644 index 0387022966..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/Tools.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type Tools = { webSearch: boolean | null, viewImage: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts b/codex-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts deleted file mode 100644 index 3d257a1c5e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/UserInfoResponse.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type UserInfoResponse = { allegedUserEmail: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts b/codex-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts deleted file mode 100644 index e70107f31e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/UserSavedConfig.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AskForApproval } from "./AskForApproval"; -import type { ForcedLoginMethod } from "./ForcedLoginMethod"; -import type { Profile } from "./Profile"; -import type { ReasoningEffort } from "./ReasoningEffort"; -import type { ReasoningSummary } from "./ReasoningSummary"; -import type { SandboxMode } from "./SandboxMode"; -import type { SandboxSettings } from "./SandboxSettings"; -import type { Tools } from "./Tools"; -import type { Verbosity } from "./Verbosity"; - -export type UserSavedConfig = { approvalPolicy: AskForApproval | null, sandboxMode: SandboxMode | null, sandboxSettings: SandboxSettings | null, forcedChatgptWorkspaceId: string | null, forcedLoginMethod: ForcedLoginMethod | null, model: string | null, modelReasoningEffort: ReasoningEffort | null, modelReasoningSummary: ReasoningSummary | null, modelVerbosity: Verbosity | null, tools: Tools | null, profile: string | null, profiles: { [key in string]?: Profile }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 81e08b1596..adaeb4c26c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -1,8 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! export type { AbsolutePathBuf } from "./AbsolutePathBuf"; -export type { AddConversationListenerParams } from "./AddConversationListenerParams"; -export type { AddConversationSubscriptionResponse } from "./AddConversationSubscriptionResponse"; export type { AgentMessageContent } from "./AgentMessageContent"; export type { AgentMessageContentDeltaEvent } from "./AgentMessageContentDeltaEvent"; export type { AgentMessageDeltaEvent } from "./AgentMessageDeltaEvent"; @@ -17,16 +15,11 @@ export type { AgentStatus } from "./AgentStatus"; export type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; export type { ApplyPatchApprovalRequestEvent } from "./ApplyPatchApprovalRequestEvent"; export type { ApplyPatchApprovalResponse } from "./ApplyPatchApprovalResponse"; -export type { ArchiveConversationParams } from "./ArchiveConversationParams"; -export type { ArchiveConversationResponse } from "./ArchiveConversationResponse"; export type { AskForApproval } from "./AskForApproval"; export type { AuthMode } from "./AuthMode"; -export type { AuthStatusChangeNotification } from "./AuthStatusChangeNotification"; export type { BackgroundEventEvent } from "./BackgroundEventEvent"; export type { ByteRange } from "./ByteRange"; export type { CallToolResult } from "./CallToolResult"; -export type { CancelLoginChatGptParams } from "./CancelLoginChatGptParams"; -export type { CancelLoginChatGptResponse } from "./CancelLoginChatGptResponse"; export type { ClientInfo } from "./ClientInfo"; export type { ClientNotification } from "./ClientNotification"; export type { ClientRequest } from "./ClientRequest"; @@ -66,16 +59,12 @@ export type { ExecCommandEndEvent } from "./ExecCommandEndEvent"; export type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent"; export type { ExecCommandSource } from "./ExecCommandSource"; export type { ExecCommandStatus } from "./ExecCommandStatus"; -export type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams"; -export type { ExecOneOffCommandResponse } from "./ExecOneOffCommandResponse"; export type { ExecOutputStream } from "./ExecOutputStream"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExitedReviewModeEvent } from "./ExitedReviewModeEvent"; export type { FileChange } from "./FileChange"; export type { FileSystemPermissions } from "./FileSystemPermissions"; export type { ForcedLoginMethod } from "./ForcedLoginMethod"; -export type { ForkConversationParams } from "./ForkConversationParams"; -export type { ForkConversationResponse } from "./ForkConversationResponse"; export type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; export type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; export type { FunctionCallOutputPayload } from "./FunctionCallOutputPayload"; @@ -89,8 +78,6 @@ export type { GetAuthStatusResponse } from "./GetAuthStatusResponse"; export type { GetConversationSummaryParams } from "./GetConversationSummaryParams"; export type { GetConversationSummaryResponse } from "./GetConversationSummaryResponse"; export type { GetHistoryEntryResponseEvent } from "./GetHistoryEntryResponseEvent"; -export type { GetUserAgentResponse } from "./GetUserAgentResponse"; -export type { GetUserSavedConfigResponse } from "./GetUserSavedConfigResponse"; export type { GhostCommit } from "./GhostCommit"; export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; @@ -99,25 +86,15 @@ export type { HistoryEntry } from "./HistoryEntry"; export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; -export type { InputItem } from "./InputItem"; export type { InputModality } from "./InputModality"; -export type { InterruptConversationParams } from "./InterruptConversationParams"; -export type { InterruptConversationResponse } from "./InterruptConversationResponse"; export type { ItemCompletedEvent } from "./ItemCompletedEvent"; export type { ItemStartedEvent } from "./ItemStartedEvent"; -export type { ListConversationsParams } from "./ListConversationsParams"; -export type { ListConversationsResponse } from "./ListConversationsResponse"; export type { ListCustomPromptsResponseEvent } from "./ListCustomPromptsResponseEvent"; export type { ListRemoteSkillsResponseEvent } from "./ListRemoteSkillsResponseEvent"; export type { ListSkillsResponseEvent } from "./ListSkillsResponseEvent"; export type { LocalShellAction } from "./LocalShellAction"; export type { LocalShellExecAction } from "./LocalShellExecAction"; export type { LocalShellStatus } from "./LocalShellStatus"; -export type { LoginApiKeyParams } from "./LoginApiKeyParams"; -export type { LoginApiKeyResponse } from "./LoginApiKeyResponse"; -export type { LoginChatGptCompleteNotification } from "./LoginChatGptCompleteNotification"; -export type { LoginChatGptResponse } from "./LoginChatGptResponse"; -export type { LogoutChatGptResponse } from "./LogoutChatGptResponse"; export type { MacOsAutomationValue } from "./MacOsAutomationValue"; export type { MacOsPermissions } from "./MacOsPermissions"; export type { MacOsPreferencesValue } from "./MacOsPreferencesValue"; @@ -139,8 +116,6 @@ export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; -export type { NewConversationParams } from "./NewConversationParams"; -export type { NewConversationResponse } from "./NewConversationResponse"; export type { ParsedCommand } from "./ParsedCommand"; export type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent"; export type { PatchApplyEndEvent } from "./PatchApplyEndEvent"; @@ -151,7 +126,6 @@ export type { PlanDeltaEvent } from "./PlanDeltaEvent"; export type { PlanItem } from "./PlanItem"; export type { PlanItemArg } from "./PlanItemArg"; export type { PlanType } from "./PlanType"; -export type { Profile } from "./Profile"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemEvent } from "./RawResponseItemEvent"; @@ -173,8 +147,6 @@ export type { ReasoningSummary } from "./ReasoningSummary"; export type { RejectConfig } from "./RejectConfig"; export type { RemoteSkillDownloadedEvent } from "./RemoteSkillDownloadedEvent"; export type { RemoteSkillSummary } from "./RemoteSkillSummary"; -export type { RemoveConversationListenerParams } from "./RemoveConversationListenerParams"; -export type { RemoveConversationSubscriptionResponse } from "./RemoveConversationSubscriptionResponse"; export type { RequestId } from "./RequestId"; export type { RequestUserInputEvent } from "./RequestUserInputEvent"; export type { RequestUserInputQuestion } from "./RequestUserInputQuestion"; @@ -182,8 +154,6 @@ export type { RequestUserInputQuestionOption } from "./RequestUserInputQuestionO export type { Resource } from "./Resource"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; -export type { ResumeConversationParams } from "./ResumeConversationParams"; -export type { ResumeConversationResponse } from "./ResumeConversationResponse"; export type { ReviewCodeLocation } from "./ReviewCodeLocation"; export type { ReviewDecision } from "./ReviewDecision"; export type { ReviewFinding } from "./ReviewFinding"; @@ -191,22 +161,13 @@ export type { ReviewLineRange } from "./ReviewLineRange"; export type { ReviewOutputEvent } from "./ReviewOutputEvent"; export type { ReviewRequest } from "./ReviewRequest"; export type { ReviewTarget } from "./ReviewTarget"; -export type { SandboxMode } from "./SandboxMode"; export type { SandboxPolicy } from "./SandboxPolicy"; -export type { SandboxSettings } from "./SandboxSettings"; -export type { SendUserMessageParams } from "./SendUserMessageParams"; -export type { SendUserMessageResponse } from "./SendUserMessageResponse"; -export type { SendUserTurnParams } from "./SendUserTurnParams"; -export type { SendUserTurnResponse } from "./SendUserTurnResponse"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; export type { ServiceTier } from "./ServiceTier"; export type { SessionConfiguredEvent } from "./SessionConfiguredEvent"; -export type { SessionConfiguredNotification } from "./SessionConfiguredNotification"; export type { SessionNetworkProxyRuntime } from "./SessionNetworkProxyRuntime"; export type { SessionSource } from "./SessionSource"; -export type { SetDefaultModelParams } from "./SetDefaultModelParams"; -export type { SetDefaultModelResponse } from "./SetDefaultModelResponse"; export type { Settings } from "./Settings"; export type { SkillDependencies } from "./SkillDependencies"; export type { SkillErrorInfo } from "./SkillErrorInfo"; @@ -227,7 +188,6 @@ export type { TokenCountEvent } from "./TokenCountEvent"; export type { TokenUsage } from "./TokenUsage"; export type { TokenUsageInfo } from "./TokenUsageInfo"; export type { Tool } from "./Tool"; -export type { Tools } from "./Tools"; export type { TurnAbortReason } from "./TurnAbortReason"; export type { TurnAbortedEvent } from "./TurnAbortedEvent"; export type { TurnCompleteEvent } from "./TurnCompleteEvent"; @@ -237,11 +197,9 @@ export type { TurnStartedEvent } from "./TurnStartedEvent"; export type { UndoCompletedEvent } from "./UndoCompletedEvent"; export type { UndoStartedEvent } from "./UndoStartedEvent"; export type { UpdatePlanArgs } from "./UpdatePlanArgs"; -export type { UserInfoResponse } from "./UserInfoResponse"; export type { UserInput } from "./UserInput"; export type { UserMessageEvent } from "./UserMessageEvent"; export type { UserMessageItem } from "./UserMessageItem"; -export type { UserSavedConfig } from "./UserSavedConfig"; export type { Verbosity } from "./Verbosity"; export type { ViewImageToolCallEvent } from "./ViewImageToolCallEvent"; export type { WarningEvent } from "./WarningEvent"; diff --git a/codex-rs/app-server-protocol/src/bin/export.rs b/codex-rs/app-server-protocol/src/bin/export.rs index d029ecbffa..10541d1fcb 100644 --- a/codex-rs/app-server-protocol/src/bin/export.rs +++ b/codex-rs/app-server-protocol/src/bin/export.rs @@ -14,9 +14,21 @@ struct Args { /// Optional Prettier executable path to format generated TypeScript files #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] prettier: Option, + + /// Include experimental API methods and fields in generated output. + #[arg(long = "experimental")] + experimental: bool, } fn main() -> Result<()> { let args = Args::parse(); - codex_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) + codex_app_server_protocol::generate_ts_with_options( + &args.out_dir, + args.prettier.as_deref(), + codex_app_server_protocol::GenerateTsOptions { + experimental_api: args.experimental, + ..codex_app_server_protocol::GenerateTsOptions::default() + }, + )?; + codex_app_server_protocol::generate_json_with_experimental(&args.out_dir, args.experimental) } diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index b48651c03c..aad5ab7233 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -37,36 +37,17 @@ use ts_rs::TS; const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"]; const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"]; -const V1_CLIENT_REQUEST_METHODS: &[&str] = &[ - "newConversation", - "getConversationSummary", - "listConversations", - "resumeConversation", - "forkConversation", - "archiveConversation", - "sendUserMessage", - "sendUserTurn", - "interruptConversation", - "addConversationListener", - "removeConversationListener", - "gitDiffToRemote", - "loginApiKey", - "loginChatGpt", - "cancelLoginChatGpt", - "logoutChatGpt", - "getAuthStatus", - "getUserSavedConfig", - "setDefaultModel", - "getUserAgent", - "userInfo", - "execOneOffCommand", -]; -const EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON: &[&str] = &[ - "authStatusChange", - "loginChatGptComplete", - "sessionConfigured", - "rawResponseItem/completed", +const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "ServerNotification", + "ServerRequest", ]; +const FLAT_V2_SHARED_DEFINITIONS: &[&str] = &["ClientRequest", "EventMsg", "ServerNotification"]; +const V1_CLIENT_REQUEST_METHODS: &[&str] = + &["getConversationSummary", "gitDiffToRemote", "getAuthStatus"]; +const EXCLUDED_SERVER_NOTIFICATION_METHODS_FOR_JSON: &[&str] = &["rawResponseItem/completed"]; #[derive(Clone)] pub struct GeneratedSchema { @@ -136,6 +117,7 @@ pub fn generate_ts_with_options( ServerRequest::export_all_to(out_dir)?; export_server_responses(out_dir)?; ServerNotification::export_all_to(out_dir)?; + EventMsg::export_all_to(out_dir)?; if !options.experimental_api { filter_experimental_ts(out_dir)?; @@ -223,6 +205,11 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - out_dir.join("codex_app_server_protocol.schemas.json"), &bundle, )?; + let flat_v2_bundle = build_flat_v2_schema(&bundle)?; + write_pretty_json( + out_dir.join("codex_app_server_protocol.v2.schemas.json"), + &flat_v2_bundle, + )?; if !experimental_api { filter_experimental_json_files(out_dir)?; @@ -870,14 +857,6 @@ impl Depth { } fn build_schema_bundle(schemas: Vec) -> Result { - const SPECIAL_DEFINITIONS: &[&str] = &[ - "ClientNotification", - "ClientRequest", - "EventMsg", - "ServerNotification", - "ServerRequest", - ]; - let namespaced_types = collect_namespaced_types(&schemas); let mut definitions = Map::new(); @@ -895,6 +874,8 @@ fn build_schema_bundle(schemas: Vec) -> Result { if let Some(ref ns) = namespace { rewrite_refs_to_namespace(&mut value, ns); + } else { + rewrite_refs_to_known_namespaces(&mut value, &namespaced_types); } let mut forced_namespace_refs: Vec<(String, String)> = Vec::new(); @@ -958,6 +939,210 @@ fn build_schema_bundle(schemas: Vec) -> Result { Ok(Value::Object(root)) } +/// Build a datamodel-code-generator-friendly v2 bundle from the mixed export. +/// +/// The full bundle keeps v2 schemas nested under `definitions.v2`, plus a few +/// shared root definitions like `ClientRequest`, `EventMsg`, and +/// `ServerNotification`. Python codegen only walks one definitions map level, so +/// a direct feed would treat `v2` itself as a schema and miss unreferenced v2 +/// leaves. This helper flattens all v2 definitions to the root definitions map, +/// then pulls in the shared root schemas and any non-v2 transitive deps they +/// still reference. Keep the shared root unions intact here: some valid +/// request/notification/event variants are inline or only reference shared root +/// helpers, so filtering them by the presence of a `#/definitions/v2/` ref +/// would silently drop real API surface from the flat bundle. +fn build_flat_v2_schema(bundle: &Value) -> Result { + let Value::Object(root) = bundle else { + return Err(anyhow!("expected bundle root to be an object")); + }; + let definitions = root + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected bundle definitions map"))?; + let v2_definitions = definitions + .get("v2") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected v2 namespace in bundle definitions"))?; + + let mut flat_root = root.clone(); + let title = root + .get("title") + .and_then(Value::as_str) + .unwrap_or("CodexAppServerProtocol"); + let mut flat_definitions = v2_definitions.clone(); + let mut shared_definitions = Map::new(); + let mut non_v2_refs = HashSet::new(); + + for shared in FLAT_V2_SHARED_DEFINITIONS { + let Some(shared_schema) = definitions.get(*shared) else { + continue; + }; + let shared_schema = shared_schema.clone(); + non_v2_refs.extend(collect_non_v2_refs(&shared_schema)); + shared_definitions.insert((*shared).to_string(), shared_schema); + } + + for name in collect_definition_dependencies(definitions, non_v2_refs) { + if name == "v2" || flat_definitions.contains_key(&name) { + continue; + } + if let Some(schema) = definitions.get(&name) { + flat_definitions.insert(name, schema.clone()); + } + } + + flat_definitions.extend(shared_definitions); + flat_root.insert("title".to_string(), Value::String(format!("{title}V2"))); + flat_root.insert("definitions".to_string(), Value::Object(flat_definitions)); + let mut flat_bundle = Value::Object(flat_root); + rewrite_ref_prefix(&mut flat_bundle, "#/definitions/v2/", "#/definitions/"); + ensure_no_ref_prefix(&flat_bundle, "#/definitions/v2/", "flat v2")?; + ensure_referenced_definitions_present(&flat_bundle, "flat v2")?; + Ok(flat_bundle) +} + +fn collect_non_v2_refs(value: &Value) -> HashSet { + let mut refs = HashSet::new(); + collect_non_v2_refs_inner(value, &mut refs); + refs +} + +fn collect_non_v2_refs_inner(value: &Value, refs: &mut HashSet) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + && !reference.starts_with("#/definitions/v2/") + { + refs.insert(name.to_string()); + } + for child in obj.values() { + collect_non_v2_refs_inner(child, refs); + } + } + Value::Array(items) => { + for child in items { + collect_non_v2_refs_inner(child, refs); + } + } + _ => {} + } +} + +fn collect_definition_dependencies( + definitions: &Map, + names: HashSet, +) -> HashSet { + let mut seen = HashSet::new(); + let mut to_process: Vec = names.into_iter().collect(); + while let Some(name) = to_process.pop() { + if !seen.insert(name.clone()) { + continue; + } + let Some(schema) = definitions.get(&name) else { + continue; + }; + for dep in collect_non_v2_refs(schema) { + if !seen.contains(&dep) { + to_process.push(dep); + } + } + } + seen +} + +fn rewrite_ref_prefix(value: &mut Value, prefix: &str, replacement: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + *reference = reference.replace(prefix, replacement); + } + for child in obj.values_mut() { + rewrite_ref_prefix(child, prefix, replacement); + } + } + Value::Array(items) => { + for child in items { + rewrite_ref_prefix(child, prefix, replacement); + } + } + _ => {} + } +} + +fn ensure_no_ref_prefix(value: &Value, prefix: &str, label: &str) -> Result<()> { + if let Some(reference) = first_ref_with_prefix(value, prefix) { + return Err(anyhow!( + "{label} schema still references namespaced definitions; found {reference}" + )); + } + Ok(()) +} + +fn first_ref_with_prefix(value: &Value, prefix: &str) -> Option { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && reference.starts_with(prefix) + { + return Some(reference.clone()); + } + obj.values() + .find_map(|child| first_ref_with_prefix(child, prefix)) + } + Value::Array(items) => items + .iter() + .find_map(|child| first_ref_with_prefix(child, prefix)), + _ => None, + } +} + +fn ensure_referenced_definitions_present(schema: &Value, label: &str) -> Result<()> { + let definitions = schema + .get("definitions") + .and_then(Value::as_object) + .ok_or_else(|| anyhow!("expected definitions map in {label} schema"))?; + let mut missing = HashSet::new(); + collect_missing_definitions(schema, definitions, &mut missing); + if missing.is_empty() { + return Ok(()); + } + let mut missing_names: Vec = missing.into_iter().collect(); + missing_names.sort(); + Err(anyhow!( + "{label} schema missing definitions: {}", + missing_names.join(", ") + )) +} + +fn collect_missing_definitions( + value: &Value, + definitions: &Map, + missing: &mut HashSet, +) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get("$ref") + && let Some(name) = reference.strip_prefix("#/definitions/") + { + let name = name.split('/').next().unwrap_or(name); + if !definitions.contains_key(name) { + missing.insert(name.to_string()); + } + } + for child in obj.values() { + collect_missing_definitions(child, definitions, missing); + } + } + Value::Array(items) => { + for child in items { + collect_missing_definitions(child, definitions, missing); + } + } + _ => {} + } +} + fn insert_into_namespace( definitions: &mut Map, namespace: &str, @@ -1230,6 +1415,43 @@ fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { } } +/// Recursively rewrite bare root definition refs to the namespace that owns the +/// referenced type in the bundle. +/// +/// The mixed export contains shared root helper schemas that are intentionally +/// left outside the `v2` namespace, but some of their extracted child +/// definitions still contain refs like `#/definitions/ThreadId`. When the real +/// schema only exists under `#/definitions/v2/ThreadId`, those refs become +/// dangling and downstream codegen falls back to placeholder `Any` models. This +/// rewrite keeps the shared helpers at the root while retargeting their refs to +/// the namespaced definitions that actually exist. +fn rewrite_refs_to_known_namespaces(value: &mut Value, types: &HashMap) { + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") + && let Some(suffix) = reference.strip_prefix("#/definitions/") + { + let (name, tail) = suffix + .split_once('/') + .map_or((suffix, None), |(name, tail)| (name, Some(tail))); + if let Some(ns) = namespace_for_definition(name, types) { + let tail = tail.map_or(String::new(), |rest| format!("/{rest}")); + *reference = format!("#/definitions/{ns}/{name}{tail}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_known_namespaces(v, types); + } + } + _ => {} + } +} + fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { let mut types = HashMap::new(); for schema in schemas { @@ -1689,6 +1911,7 @@ mod tests { client_request_ts.contains("MockExperimentalMethodParams"), false ); + assert_eq!(output_dir.join("EventMsg.ts").exists(), true); let thread_start_ts = fs::read_to_string(output_dir.join("v2").join("ThreadStartParams.ts"))?; assert_eq!(thread_start_ts.contains("mockExperimentalField"), false); @@ -1986,6 +2209,284 @@ mod tests { Ok(()) } + #[test] + fn build_schema_bundle_rewrites_root_helper_refs_to_namespaced_defs() -> Result<()> { + let bundle = build_schema_bundle(vec![ + GeneratedSchema { + namespace: None, + logical_name: "LegacyEnvelope".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "LegacyEnvelope", + "type": "object", + "properties": { + "current_thread": { "$ref": "#/definitions/ThreadId" }, + "turn_item": { "$ref": "#/definitions/TurnItem" } + }, + "definitions": { + "TurnItem": { + "type": "object", + "properties": { + "thread_id": { "$ref": "#/definitions/ThreadId" }, + "phase": { "$ref": "#/definitions/MessagePhase" }, + "content": { + "type": "array", + "items": { "$ref": "#/definitions/UserInput" } + } + } + } + } + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "ThreadId".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "ThreadId", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "MessagePhase".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "MessagePhase", + "type": "string" + }), + }, + GeneratedSchema { + namespace: Some("v2".to_string()), + logical_name: "UserInput".to_string(), + in_v1_dir: false, + value: serde_json::json!({ + "title": "UserInput", + "type": "string" + }), + }, + ])?; + + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["current_thread"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["LegacyEnvelope"]["properties"]["turn_item"]["$ref"], + serde_json::json!("#/definitions/TurnItem") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["thread_id"]["$ref"], + serde_json::json!("#/definitions/v2/ThreadId") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["phase"]["$ref"], + serde_json::json!("#/definitions/v2/MessagePhase") + ); + assert_eq!( + bundle["definitions"]["TurnItem"]["properties"]["content"]["items"]["$ref"], + serde_json::json!("#/definitions/v2/UserInput") + ); + + Ok(()) + } + + #[test] + fn build_flat_v2_schema_keeps_shared_root_schemas_and_dependencies() -> Result<()> { + let bundle = serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CodexAppServerProtocol", + "type": "object", + "definitions": { + "ClientRequest": { + "oneOf": [ + { + "title": "StartRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/v2/ThreadStartParams" }, + "shared": { "$ref": "#/definitions/SharedHelper" } + } + }, + { + "title": "InitializeRequest", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/InitializeParams" } + } + }, + { + "title": "LogoutRequest", + "type": "object", + "properties": { + "params": { "type": "null" } + } + } + ] + }, + "EventMsg": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedEventMsg" }, + { + "title": "WarningEventMsg", + "type": "object", + "properties": { + "message": { "type": "string" }, + "type": { + "enum": ["warning"], + "type": "string" + } + }, + "required": ["message", "type"] + } + ] + }, + "ServerNotification": { + "oneOf": [ + { "$ref": "#/definitions/v2/ThreadStartedNotification" }, + { + "title": "ServerRequestResolvedNotification", + "type": "object", + "properties": { + "params": { "$ref": "#/definitions/ServerRequestResolvedNotificationPayload" } + } + } + ] + }, + "SharedHelper": { + "type": "object", + "properties": { + "leaf": { "$ref": "#/definitions/SharedLeaf" } + } + }, + "SharedLeaf": { + "title": "SharedLeaf", + "type": "string" + }, + "InitializeParams": { + "title": "InitializeParams", + "type": "string" + }, + "ServerRequestResolvedNotificationPayload": { + "title": "ServerRequestResolvedNotificationPayload", + "type": "string" + }, + "v2": { + "ThreadStartParams": { + "title": "ThreadStartParams", + "type": "object", + "properties": { + "cwd": { "type": "string" } + } + }, + "ThreadStartResponse": { + "title": "ThreadStartResponse", + "type": "object", + "properties": { + "ok": { "type": "boolean" } + } + }, + "ThreadStartedEventMsg": { + "title": "ThreadStartedEventMsg", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + }, + "ThreadStartedNotification": { + "title": "ThreadStartedNotification", + "type": "object", + "properties": { + "thread_id": { "type": "string" } + } + } + } + } + }); + + let flat_bundle = build_flat_v2_schema(&bundle)?; + let definitions = flat_bundle["definitions"] + .as_object() + .expect("flat v2 schema should include definitions"); + + assert_eq!( + flat_bundle["title"], + serde_json::json!("CodexAppServerProtocolV2") + ); + assert_eq!(definitions.contains_key("v2"), false); + assert_eq!(definitions.contains_key("ThreadStartParams"), true); + assert_eq!(definitions.contains_key("ThreadStartResponse"), true); + assert_eq!(definitions.contains_key("ThreadStartedEventMsg"), true); + assert_eq!(definitions.contains_key("ThreadStartedNotification"), true); + assert_eq!(definitions.contains_key("SharedHelper"), true); + assert_eq!(definitions.contains_key("SharedLeaf"), true); + assert_eq!(definitions.contains_key("InitializeParams"), true); + assert_eq!( + definitions.contains_key("ServerRequestResolvedNotificationPayload"), + true + ); + let client_request_titles: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("ClientRequest should remain a oneOf") + .iter() + .map(|variant| { + variant["title"] + .as_str() + .expect("ClientRequest variant should have a title") + .to_string() + }) + .collect(); + assert_eq!( + client_request_titles, + BTreeSet::from([ + "InitializeRequest".to_string(), + "LogoutRequest".to_string(), + "StartRequest".to_string(), + ]) + ); + let event_titles: BTreeSet = definitions["EventMsg"]["oneOf"] + .as_array() + .expect("EventMsg should remain a oneOf") + .iter() + .map(|variant| { + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + event_titles, + BTreeSet::from(["".to_string(), "WarningEventMsg".to_string(),]) + ); + let notification_titles: BTreeSet = definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("ServerNotification should remain a oneOf") + .iter() + .map(|variant| { + variant + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string() + }) + .collect(); + assert_eq!( + notification_titles, + BTreeSet::from([ + "".to_string(), + "ServerRequestResolvedNotification".to_string(), + ]) + ); + assert_eq!( + first_ref_with_prefix(&flat_bundle, "#/definitions/v2/").is_none(), + true + ); + + Ok(()) + } + #[test] fn experimental_type_fields_ts_filter_handles_interface_shape() -> Result<()> { let output_dir = std::env::temp_dir().join(format!("codex_ts_filter_{}", Uuid::now_v7())); @@ -2114,6 +2615,99 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k bundle_json.contains("MockExperimentalMethodResponse"), false ); + let flat_v2_bundle_json = + fs::read_to_string(output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + assert_eq!(flat_v2_bundle_json.contains("mockExperimentalField"), false); + assert_eq!(flat_v2_bundle_json.contains("additionalPermissions"), false); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodParams"), + false + ); + assert_eq!( + flat_v2_bundle_json.contains("MockExperimentalMethodResponse"), + false + ); + assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false); + assert_eq!( + flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""), + true + ); + let flat_v2_bundle = + read_json_value(&output_dir.join("codex_app_server_protocol.v2.schemas.json"))?; + let definitions = flat_v2_bundle["definitions"] + .as_object() + .expect("flat v2 bundle should include definitions"); + let client_request_methods: BTreeSet = definitions["ClientRequest"]["oneOf"] + .as_array() + .expect("flat v2 ClientRequest should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_client_request_methods: Vec = [ + "account/logout", + "account/rateLimits/read", + "config/mcpServer/reload", + "configRequirements/read", + "fuzzyFileSearch", + "initialize", + ] + .into_iter() + .filter(|method| !client_request_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_client_request_methods, Vec::::new()); + let server_notification_methods: BTreeSet = + definitions["ServerNotification"]["oneOf"] + .as_array() + .expect("flat v2 ServerNotification should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["method"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_server_notification_methods: Vec = [ + "fuzzyFileSearch/sessionCompleted", + "fuzzyFileSearch/sessionUpdated", + "serverRequest/resolved", + ] + .into_iter() + .filter(|method| !server_notification_methods.contains(*method)) + .map(str::to_string) + .collect(); + assert_eq!(missing_server_notification_methods, Vec::::new()); + let event_types: BTreeSet = definitions["EventMsg"]["oneOf"] + .as_array() + .expect("flat v2 EventMsg should remain a oneOf") + .iter() + .filter_map(|variant| { + variant["properties"]["type"]["enum"] + .as_array() + .and_then(|values| values.first()) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + let missing_event_types: Vec = [ + "agent_message_delta", + "task_complete", + "warning", + "web_search_begin", + ] + .into_iter() + .filter(|event_type| !event_types.contains(*event_type)) + .map(str::to_string) + .collect(); + assert_eq!(missing_event_types, Vec::::new()); assert_eq!( output_dir .join("v2") diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f63ebd37a2..98efcdd290 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -403,95 +403,19 @@ client_request_definitions! { }, /// DEPRECATED APIs below - NewConversation { - params: v1::NewConversationParams, - response: v1::NewConversationResponse, - }, GetConversationSummary { params: v1::GetConversationSummaryParams, response: v1::GetConversationSummaryResponse, }, - /// List recorded Codex conversations (rollouts) with optional pagination and search. - ListConversations { - params: v1::ListConversationsParams, - response: v1::ListConversationsResponse, - }, - /// Resume a recorded Codex conversation from a rollout file. - ResumeConversation { - params: v1::ResumeConversationParams, - response: v1::ResumeConversationResponse, - }, - /// Fork a recorded Codex conversation into a new session. - ForkConversation { - params: v1::ForkConversationParams, - response: v1::ForkConversationResponse, - }, - ArchiveConversation { - params: v1::ArchiveConversationParams, - response: v1::ArchiveConversationResponse, - }, - SendUserMessage { - params: v1::SendUserMessageParams, - response: v1::SendUserMessageResponse, - }, - SendUserTurn { - params: v1::SendUserTurnParams, - response: v1::SendUserTurnResponse, - }, - InterruptConversation { - params: v1::InterruptConversationParams, - response: v1::InterruptConversationResponse, - }, - AddConversationListener { - params: v1::AddConversationListenerParams, - response: v1::AddConversationSubscriptionResponse, - }, - RemoveConversationListener { - params: v1::RemoveConversationListenerParams, - response: v1::RemoveConversationSubscriptionResponse, - }, GitDiffToRemote { params: v1::GitDiffToRemoteParams, response: v1::GitDiffToRemoteResponse, }, - LoginApiKey { - params: v1::LoginApiKeyParams, - response: v1::LoginApiKeyResponse, - }, - LoginChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::LoginChatGptResponse, - }, - // DEPRECATED in favor of CancelLoginAccount - CancelLoginChatGpt { - params: v1::CancelLoginChatGptParams, - response: v1::CancelLoginChatGptResponse, - }, - LogoutChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::LogoutChatGptResponse, - }, /// DEPRECATED in favor of GetAccount GetAuthStatus { params: v1::GetAuthStatusParams, response: v1::GetAuthStatusResponse, }, - GetUserSavedConfig { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::GetUserSavedConfigResponse, - }, - SetDefaultModel { - params: v1::SetDefaultModelParams, - response: v1::SetDefaultModelResponse, - }, - GetUserAgent { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::GetUserAgentResponse, - }, - UserInfo { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: v1::UserInfoResponse, - }, FuzzyFileSearch { params: FuzzyFileSearchParams, response: FuzzyFileSearchResponse, @@ -511,11 +435,6 @@ client_request_definitions! { params: FuzzyFileSearchSessionStopParams, response: FuzzyFileSearchSessionStopResponse, }, - /// Execute a command (argv vector) under the server's sandbox. - ExecOneOffCommand { - params: v1::ExecOneOffCommandParams, - response: v1::ExecOneOffCommandResponse, - }, } /// Generates an `enum ServerRequest` where each variant is a request that the @@ -882,12 +801,6 @@ server_notification_definitions! { #[strum(serialize = "account/login/completed")] AccountLoginCompleted(v2::AccountLoginCompletedNotification), - /// DEPRECATED NOTIFICATIONS below - AuthStatusChange(v1::AuthStatusChangeNotification), - - /// Deprecated: use `account/login/completed` instead. - LoginChatGptComplete(v1::LoginChatGptCompleteNotification), - SessionConfigured(v1::SessionConfiguredNotification), } client_notification_definitions! { @@ -901,7 +814,6 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::parse_command::ParsedCommand; - use codex_protocol::protocol::AskForApproval; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -912,37 +824,19 @@ mod tests { } #[test] - fn serialize_new_conversation() -> Result<()> { - let request = ClientRequest::NewConversation { + fn serialize_get_conversation_summary() -> Result<()> { + let request = ClientRequest::GetConversationSummary { request_id: RequestId::Integer(42), - params: v1::NewConversationParams { - model: Some("gpt-5.1-codex-max".to_string()), - model_provider: None, - profile: None, - cwd: None, - approval_policy: Some(AskForApproval::OnRequest), - sandbox: None, - config: None, - base_instructions: None, - developer_instructions: None, - compact_prompt: None, - include_apply_patch_tool: None, + params: v1::GetConversationSummaryParams::ThreadId { + conversation_id: ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?, }, }; assert_eq!( json!({ - "method": "newConversation", + "method": "getConversationSummary", "id": 42, "params": { - "model": "gpt-5.1-codex-max", - "modelProvider": null, - "profile": null, - "cwd": null, - "approvalPolicy": "on-request", - "sandbox": null, - "config": null, - "baseInstructions": null, - "includeApplyPatchTool": null + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index ed1ca86229..d393f97f72 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -596,34 +596,3 @@ impl InputItem { } } } - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -/// Deprecated in favor of AccountLoginCompletedNotification. -pub struct LoginChatGptCompleteNotification { - #[schemars(with = "String")] - pub login_id: Uuid, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct SessionConfiguredNotification { - pub session_id: ThreadId, - pub model: String, - pub service_tier: Option, - pub reasoning_effort: Option, - pub history_log_id: u64, - #[ts(type = "number")] - pub history_entry_count: usize, - pub initial_messages: Option>, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -/// Deprecated notification. Use AccountUpdatedNotification instead. -pub struct AuthStatusChangeNotification { - pub auth_method: Option, -} diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index d8f06be421..7baad9be66 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -23,8 +23,7 @@ use anyhow::bail; use clap::ArgAction; use clap::Parser; use clap::Subcommand; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; +use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; @@ -40,22 +39,16 @@ use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; -use codex_app_server_protocol::InputItem; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::LoginChatGptCompleteNotification; -use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::ReadOnlyAccess; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -69,9 +62,6 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_protocol::ThreadId; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::W3cTraceContext; use serde::Serialize; use serde::de::DeserializeOwned; @@ -502,26 +492,16 @@ fn send_message( config_overrides: &[String], user_message: String, ) -> Result<()> { - with_client(endpoint, config_overrides, |client| { - let initialize = client.initialize()?; - println!("< initialize response: {initialize:?}"); - - let conversation = client.start_thread()?; - println!("< newConversation response: {conversation:?}"); - - let subscription = client.add_conversation_listener(&conversation.conversation_id)?; - println!("< addConversationListener response: {subscription:?}"); - - let send_response = - client.send_user_message(&conversation.conversation_id, &user_message)?; - println!("< sendUserMessage response: {send_response:?}"); - - client.stream_conversation(&conversation.conversation_id)?; - - client.remove_thread_listener(subscription.subscription_id)?; - - Ok(()) - }) + let dynamic_tools = None; + send_message_v2_with_policies( + endpoint, + config_overrides, + user_message, + false, + None, + None, + &dynamic_tools, + ) } pub fn send_message_v2( @@ -877,15 +857,15 @@ fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { let initialize = client.initialize()?; println!("< initialize response: {initialize:?}"); - let login_response = client.login_chat_gpt()?; - println!("< loginChatGpt response: {login_response:?}"); - println!( - "Open the following URL in your browser to continue:\n{}", - login_response.auth_url - ); + let login_response = client.login_account_chatgpt()?; + println!("< account/login/start response: {login_response:?}"); + let LoginAccountResponse::Chatgpt { login_id, auth_url } = login_response else { + bail!("expected chatgpt login response"); + }; + println!("Open the following URL in your browser to continue:\n{auth_url}"); - let completion = client.wait_for_login_completion(&login_response.login_id)?; - println!("< loginChatGptComplete notification: {completion:?}"); + let completion = client.wait_for_account_login_completion(&login_id)?; + println!("< account/login/completed notification: {completion:?}"); if completion.success { println!("Login succeeded."); @@ -896,7 +876,7 @@ fn test_login(endpoint: &Endpoint, config_overrides: &[String]) -> Result<()> { completion .error .as_deref() - .unwrap_or("unknown error from loginChatGptComplete") + .unwrap_or("unknown error from account/login/completed") ); } }) @@ -1142,69 +1122,6 @@ impl CodexClient { Ok(response) } - fn start_thread(&mut self) -> Result { - let request_id = self.request_id(); - let request = ClientRequest::NewConversation { - request_id: request_id.clone(), - params: NewConversationParams::default(), - }; - - self.send_request(request, request_id, "newConversation") - } - - fn add_conversation_listener( - &mut self, - conversation_id: &ThreadId, - ) -> Result { - let request_id = self.request_id(); - let request = ClientRequest::AddConversationListener { - request_id: request_id.clone(), - params: AddConversationListenerParams { - conversation_id: *conversation_id, - experimental_raw_events: false, - }, - }; - - self.send_request(request, request_id, "addConversationListener") - } - - fn remove_thread_listener(&mut self, subscription_id: Uuid) -> Result<()> { - let request_id = self.request_id(); - let request = ClientRequest::RemoveConversationListener { - request_id: request_id.clone(), - params: codex_app_server_protocol::RemoveConversationListenerParams { subscription_id }, - }; - - self.send_request::( - request, - request_id, - "removeConversationListener", - )?; - - Ok(()) - } - - fn send_user_message( - &mut self, - conversation_id: &ThreadId, - message: &str, - ) -> Result { - let request_id = self.request_id(); - let request = ClientRequest::SendUserMessage { - request_id: request_id.clone(), - params: SendUserMessageParams { - conversation_id: *conversation_id, - items: vec![InputItem::Text { - text: message.to_string(), - // Test client sends plain text without UI element ranges. - text_elements: Vec::new(), - }], - }, - }; - - self.send_request(request, request_id, "sendUserMessage") - } - fn thread_start(&mut self, params: ThreadStartParams) -> Result { let request_id = self.request_id(); let request = ClientRequest::ThreadStart { @@ -1235,14 +1152,14 @@ impl CodexClient { self.send_request(request, request_id, "turn/start") } - fn login_chat_gpt(&mut self) -> Result { + fn login_account_chatgpt(&mut self) -> Result { let request_id = self.request_id(); - let request = ClientRequest::LoginChatGpt { + let request = ClientRequest::LoginAccount { request_id: request_id.clone(), - params: None, + params: codex_app_server_protocol::LoginAccountParams::Chatgpt, }; - self.send_request(request, request_id, "loginChatGpt") + self.send_request(request, request_id, "account/login/start") } fn get_account_rate_limits(&mut self) -> Result { @@ -1275,77 +1192,31 @@ impl CodexClient { self.send_request(request, request_id, "thread/list") } - fn stream_conversation(&mut self, conversation_id: &ThreadId) -> Result<()> { - loop { - let notification = self.next_notification()?; - - if !notification.method.starts_with("codex/event/") { - continue; - } - - if let Some(event) = self.extract_event(notification, conversation_id)? { - match &event.msg { - EventMsg::AgentMessage(event) => { - println!("{}", event.message); - } - EventMsg::AgentMessageDelta(event) => { - print!("{}", event.delta); - std::io::stdout().flush().ok(); - } - EventMsg::TurnComplete(event) => { - println!("\n[task complete: {event:?}]"); - break; - } - EventMsg::TurnAborted(event) => { - println!("\n[turn aborted: {:?}]", event.reason); - break; - } - EventMsg::Error(event) => { - println!("[error] {event:?}"); - } - _ => { - println!("[UNKNOWN EVENT] {:?}", event.msg); - } - } - } - } - - Ok(()) - } - - fn wait_for_login_completion( + fn wait_for_account_login_completion( &mut self, - expected_login_id: &Uuid, - ) -> Result { + expected_login_id: &str, + ) -> Result { loop { let notification = self.next_notification()?; if let Ok(server_notification) = ServerNotification::try_from(notification) { match server_notification { - ServerNotification::LoginChatGptComplete(completion) => { - if &completion.login_id == expected_login_id { + ServerNotification::AccountLoginCompleted(completion) => { + if completion.login_id.as_deref() == Some(expected_login_id) { return Ok(completion); } println!( - "[ignoring loginChatGptComplete for unexpected login_id: {}]", + "[ignoring account/login/completed for unexpected login_id: {:?}]", completion.login_id ); } - ServerNotification::AuthStatusChange(status) => { - println!("< authStatusChange notification: {status:?}"); - } ServerNotification::AccountRateLimitsUpdated(snapshot) => { println!("< accountRateLimitsUpdated notification: {snapshot:?}"); } - ServerNotification::SessionConfigured(_) => { - // SessionConfigured notifications are unrelated to login; skip. - } _ => {} } } - - // Not a server notification (likely a conversation event); keep waiting. } } @@ -1419,36 +1290,6 @@ impl CodexClient { } } - fn extract_event( - &self, - notification: JSONRPCNotification, - conversation_id: &ThreadId, - ) -> Result> { - let params = notification - .params - .context("event notification missing params")?; - - let mut map = match params { - Value::Object(map) => map, - other => bail!("unexpected params shape: {other:?}"), - }; - - let conversation_value = map - .remove("conversationId") - .context("event missing conversationId")?; - let notification_conversation: ThreadId = serde_json::from_value(conversation_value) - .context("conversationId was not a valid UUID")?; - - if ¬ification_conversation != conversation_id { - return Ok(None); - } - - let event_value = Value::Object(map); - let event: Event = - serde_json::from_value(event_value).context("failed to decode event payload")?; - Ok(Some(event)) - } - fn send_request( &mut self, request: ClientRequest, diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 8a4ec05fd3..ba2355f675 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -32,6 +32,7 @@ codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } codex-feedback = { workspace = true } codex-rmcp-client = { workspace = true } +codex-state = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } @@ -64,11 +65,9 @@ axum = { workspace = true, default-features = false, features = [ "tokio", ] } base64 = { workspace = true } -codex-execpolicy = { workspace = true } core_test_support = { workspace = true } codex-state = { workspace = true } codex-utils-cargo-bin = { workspace = true } -os_info = { workspace = true } pretty_assertions = { workspace = true } rmcp = { workspace = true, default-features = false, features = [ "server", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 3c1c29ecab..1c18345559 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -20,21 +20,15 @@ use chrono::Utc; use codex_app_server_protocol::Account; use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppsListParams; use codex_app_server_protocol::AppsListResponse; -use codex_app_server_protocol::ArchiveConversationParams; -use codex_app_server_protocol::ArchiveConversationResponse; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::AuthStatusChangeNotification; use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginAccountResponse; use codex_app_server_protocol::CancelLoginAccountStatus; -use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; @@ -49,8 +43,6 @@ use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; -use codex_app_server_protocol::ForkConversationParams; -use codex_app_server_protocol::ForkConversationResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; use codex_app_server_protocol::FuzzyFileSearchSessionStartParams; @@ -66,26 +58,16 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; -use codex_app_server_protocol::GetUserAgentResponse; -use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::HazelnutScope as ApiHazelnutScope; -use codex_app_server_protocol::InputItem as WireInputItem; -use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::ListConversationsParams; -use codex_app_server_protocol::ListConversationsResponse; use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::LoginApiKeyParams; -use codex_app_server_protocol::LoginApiKeyResponse; -use codex_app_server_protocol::LoginChatGptCompleteNotification; -use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; -use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; use codex_app_server_protocol::McpServerOauthLoginParams; use codex_app_server_protocol::McpServerOauthLoginResponse; @@ -95,28 +77,15 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::ProductSurface as ApiProductSurface; -use codex_app_server_protocol::RemoveConversationListenerParams; -use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ResumeConversationParams; -use codex_app_server_protocol::ResumeConversationResponse; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ReviewStartResponse; use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::SandboxMode; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use codex_app_server_protocol::SendUserTurnParams; -use codex_app_server_protocol::SendUserTurnResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestResolvedNotification; -use codex_app_server_protocol::SessionConfiguredNotification; -use codex_app_server_protocol::SetDefaultModelParams; -use codex_app_server_protocol::SetDefaultModelResponse; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; use codex_app_server_protocol::SkillsListParams; @@ -176,9 +145,7 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_app_server_protocol::UserSavedConfig; use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; @@ -205,13 +172,11 @@ use codex_core::auth::login_with_api_key; use codex_core::auth::login_with_chatgpt_auth_tokens; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigService; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::CloudRequirementsLoader; -use codex_core::default_client::get_codex_user_agent; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; use codex_core::exec::ExecParams; @@ -274,6 +239,7 @@ use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use codex_protocol::user_input::UserInput as CoreInputItem; use codex_rmcp_client::perform_oauth_login_return_url; +use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::collections::HashSet; @@ -329,7 +295,7 @@ struct ActiveLogin { #[derive(Clone, Copy, Debug)] enum CancelLoginError { - NotFound(Uuid), + NotFound, } enum AppListLoadResult { @@ -383,10 +349,12 @@ pub(crate) struct CodexMessageProcessor { pending_fuzzy_searches: Arc>>>, fuzzy_search_sessions: Arc>>, feedback: CodexFeedback, + log_db: Option, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) enum ApiVersion { + #[allow(dead_code)] V1, #[default] V2, @@ -417,6 +385,7 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) cli_overrides: Vec<(String, TomlValue)>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, + pub(crate) log_db: Option, } impl CodexMessageProcessor { @@ -461,6 +430,7 @@ impl CodexMessageProcessor { cli_overrides, cloud_requirements, feedback, + log_db, } = args; Self { auth_manager, @@ -477,6 +447,7 @@ impl CodexMessageProcessor { pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), feedback, + log_db, } } @@ -713,21 +684,10 @@ impl CodexMessageProcessor { self.review_start(to_connection_request_id(request_id), params) .await; } - ClientRequest::NewConversation { request_id, params } => { - // Do not tokio::spawn() to process new_conversation() - // asynchronously because we need to ensure the conversation is - // created before processing any subsequent messages. - self.process_new_conversation(to_connection_request_id(request_id), params) - .await; - } ClientRequest::GetConversationSummary { request_id, params } => { self.get_thread_summary(to_connection_request_id(request_id), params) .await; } - ClientRequest::ListConversations { request_id, params } => { - self.handle_list_conversations(to_connection_request_id(request_id), params) - .await; - } ClientRequest::ModelList { request_id, params } => { let outgoing = self.outgoing.clone(); let thread_manager = self.thread_manager.clone(); @@ -789,100 +749,14 @@ impl CodexMessageProcessor { self.get_account(to_connection_request_id(request_id), params) .await; } - ClientRequest::ResumeConversation { request_id, params } => { - self.handle_resume_conversation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ForkConversation { request_id, params } => { - self.handle_fork_conversation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ArchiveConversation { request_id, params } => { - self.archive_conversation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::SendUserMessage { request_id, params } => { - self.send_user_message( - to_connection_request_id(request_id), - params, - app_server_client_name.clone(), - ) - .await; - } - ClientRequest::SendUserTurn { request_id, params } => { - self.send_user_turn( - to_connection_request_id(request_id), - params, - app_server_client_name.clone(), - ) - .await; - } - ClientRequest::InterruptConversation { request_id, params } => { - self.interrupt_conversation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::AddConversationListener { request_id, params } => { - self.add_conversation_listener(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::RemoveConversationListener { request_id, params } => { - self.remove_thread_listener(to_connection_request_id(request_id), params) - .await; - } ClientRequest::GitDiffToRemote { request_id, params } => { self.git_diff_to_origin(to_connection_request_id(request_id), params.cwd) .await; } - ClientRequest::LoginApiKey { request_id, params } => { - self.login_api_key_v1(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::LoginChatGpt { - request_id, - params: _, - } => { - self.login_chatgpt_v1(to_connection_request_id(request_id)) - .await; - } - ClientRequest::CancelLoginChatGpt { request_id, params } => { - self.cancel_login_chatgpt(to_connection_request_id(request_id), params.login_id) - .await; - } - ClientRequest::LogoutChatGpt { - request_id, - params: _, - } => { - self.logout_v1(to_connection_request_id(request_id)).await; - } ClientRequest::GetAuthStatus { request_id, params } => { self.get_auth_status(to_connection_request_id(request_id), params) .await; } - ClientRequest::GetUserSavedConfig { - request_id, - params: _, - } => { - self.get_user_saved_config(to_connection_request_id(request_id)) - .await; - } - ClientRequest::SetDefaultModel { request_id, params } => { - self.set_default_model(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::GetUserAgent { - request_id, - params: _, - } => { - self.get_user_agent(to_connection_request_id(request_id)) - .await; - } - ClientRequest::UserInfo { - request_id, - params: _, - } => { - self.get_user_info(to_connection_request_id(request_id)) - .await; - } ClientRequest::FuzzyFileSearch { request_id, params } => { self.fuzzy_file_search(to_connection_request_id(request_id), params) .await; @@ -903,10 +777,6 @@ impl CodexMessageProcessor { self.exec_one_off_command(to_connection_request_id(request_id), params) .await; } - ClientRequest::ExecOneOffCommand { request_id, params } => { - self.exec_one_off_command(to_connection_request_id(request_id), params.into()) - .await; - } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } | ClientRequest::ConfigBatchWrite { .. } => { @@ -1011,34 +881,6 @@ impl CodexMessageProcessor { } } - async fn login_api_key_v1( - &mut self, - request_id: ConnectionRequestId, - params: LoginApiKeyParams, - ) { - match self.login_api_key_common(¶ms).await { - Ok(()) => { - self.outgoing - .send_response(request_id, LoginApiKeyResponse {}) - .await; - - let payload = AuthStatusChangeNotification { - auth_method: self - .auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode), - }; - self.outgoing - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn login_api_key_v2( &mut self, request_id: ConnectionRequestId, @@ -1101,115 +943,6 @@ impl CodexMessageProcessor { }) } - // Deprecated in favor of login_chatgpt_v2. - async fn login_chatgpt_v1(&mut self, request_id: ConnectionRequestId) { - match self.login_chatgpt_common().await { - Ok(opts) => match run_login_server(opts) { - Ok(server) => { - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); - - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } - - // Spawn background task to monitor completion. - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - let cloud_requirements = self.cloud_requirements.clone(); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - let codex_home = self.config.codex_home.clone(); - let cli_overrides = self.cli_overrides.clone(); - let auth_url = server.auth_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await - { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) - } - }; - - let payload = LoginChatGptCompleteNotification { - login_id, - success, - error: error_msg.clone(), - }; - outgoing_clone - .send_server_notification(ServerNotification::LoginChatGptComplete( - payload, - )) - .await; - - if success { - auth_manager.reload(); - replace_cloud_requirements_loader( - cloud_requirements.as_ref(), - auth_manager.clone(), - chatgpt_base_url, - codex_home, - ); - sync_default_client_residency_requirement( - &cli_overrides, - cloud_requirements.as_ref(), - ) - .await; - - // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - outgoing_clone - .send_server_notification(ServerNotification::AuthStatusChange( - payload, - )) - .await; - } - - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { - *guard = None; - } - }); - - let response = LoginChatGptResponse { login_id, auth_url }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - }, - Err(err) => { - self.outgoing.send_error(request_id, err).await; - } - } - } - async fn login_chatgpt_v2(&mut self, request_id: ConnectionRequestId) { match self.login_chatgpt_common().await { Ok(opts) => match run_login_server(opts) { @@ -1330,25 +1063,7 @@ impl CodexMessageProcessor { } Ok(()) } else { - Err(CancelLoginError::NotFound(login_id)) - } - } - - async fn cancel_login_chatgpt(&mut self, request_id: ConnectionRequestId, login_id: Uuid) { - match self.cancel_login_chatgpt_common(login_id).await { - Ok(()) => { - self.outgoing - .send_response(request_id, CancelLoginChatGptResponse {}) - .await; - } - Err(CancelLoginError::NotFound(missing_login_id)) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("login id not found: {missing_login_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } + Err(CancelLoginError::NotFound) } } @@ -1362,7 +1077,7 @@ impl CodexMessageProcessor { Ok(uuid) => { let status = match self.cancel_login_chatgpt_common(uuid).await { Ok(()) => CancelLoginAccountStatus::Canceled, - Err(CancelLoginError::NotFound(_)) => CancelLoginAccountStatus::NotFound, + Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, }; let response = CancelLoginAccountResponse { status }; self.outgoing.send_response(request_id, response).await; @@ -1495,26 +1210,6 @@ impl CodexMessageProcessor { .map(CodexAuth::api_auth_mode)) } - async fn logout_v1(&mut self, request_id: ConnectionRequestId) { - match self.logout_common().await { - Ok(current_auth_method) => { - self.outgoing - .send_response(request_id, LogoutChatGptResponse {}) - .await; - - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - self.outgoing - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn logout_v2(&mut self, request_id: ConnectionRequestId) { match self.logout_common().await { Ok(current_auth_method) => { @@ -1646,12 +1341,6 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn get_user_agent(&self, request_id: ConnectionRequestId) { - let user_agent = get_codex_user_agent(); - let response = GetUserAgentResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; - } - async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { match self.fetch_account_rate_limits().await { Ok((rate_limits, rate_limits_by_limit_id)) => { @@ -1741,69 +1430,6 @@ impl CodexMessageProcessor { Ok((primary, rate_limits_by_limit_id)) } - async fn get_user_saved_config(&self, request_id: ConnectionRequestId) { - let service = ConfigService::new_with_defaults(self.config.codex_home.clone()); - let user_saved_config: UserSavedConfig = match service.load_user_saved_config().await { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: err.to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let response = GetUserSavedConfigResponse { - config: user_saved_config, - }; - self.outgoing.send_response(request_id, response).await; - } - - async fn get_user_info(&self, request_id: ConnectionRequestId) { - // Read alleged user email from cached auth (best-effort; not verified). - let alleged_user_email = self - .auth_manager - .auth_cached() - .and_then(|a| a.get_account_email()); - - let response = UserInfoResponse { alleged_user_email }; - self.outgoing.send_response(request_id, response).await; - } - - async fn set_default_model( - &self, - request_id: ConnectionRequestId, - params: SetDefaultModelParams, - ) { - let SetDefaultModelParams { - model, - reasoning_effort, - } = params; - - match ConfigEditsBuilder::new(&self.config.codex_home) - .with_profile(self.config.active_profile.as_deref()) - .set_model(model.as_deref(), reasoning_effort) - .apply() - .await - { - Ok(()) => { - let response = SetDefaultModelResponse {}; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to persist model selection: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn exec_one_off_command( &self, request_id: ConnectionRequestId, @@ -1925,119 +1551,6 @@ impl CodexMessageProcessor { }); } - async fn process_new_conversation( - &mut self, - request_id: ConnectionRequestId, - params: NewConversationParams, - ) { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: request_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = params; - - let typesafe_overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.clone().map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - - // Persist Windows sandbox mode. - // TODO: persist default config in general. - let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) { - match WindowsSandboxLevel::from_config(&self.config) { - WindowsSandboxLevel::Elevated => { - request_overrides - .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); - } - WindowsSandboxLevel::RestrictedToken => { - request_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("unelevated"), - ); - } - WindowsSandboxLevel::Disabled => {} - } - } - - let cloud_requirements = self.current_cloud_requirements(); - let config = match derive_config_from_params( - &self.cli_overrides, - Some(request_overrides), - typesafe_overrides, - &cloud_requirements, - ) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - match self.thread_manager.start_thread(config).await { - Ok(new_thread) => { - let NewThread { - thread_id, - session_configured, - .. - } = new_thread; - let rollout_path = match session_configured.rollout_path { - Some(path) => path, - None => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "rollout path missing for v1 conversation".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let response = NewConversationResponse { - conversation_id: thread_id, - model: session_configured.model, - reasoning_effort: session_configured.reasoning_effort, - rollout_path, - }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error creating conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn thread_start(&self, request_id: ConnectionRequestId, params: ThreadStartParams) { let ThreadStartParams { model, @@ -3809,45 +3322,6 @@ impl CodexMessageProcessor { } } - async fn handle_list_conversations( - &self, - request_id: ConnectionRequestId, - params: ListConversationsParams, - ) { - let ListConversationsParams { - page_size, - cursor, - model_providers, - } = params; - let requested_page_size = page_size - .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) - .clamp(1, THREAD_LIST_MAX_LIMIT); - - match self - .list_threads_common( - requested_page_size, - cursor, - CoreThreadSortKey::UpdatedAt, - ThreadListFilters { - model_providers, - source_kinds: None, - archived: false, - cwd: None, - search_term: None, - }, - ) - .await - { - Ok((items, next_cursor)) => { - let response = ListConversationsResponse { items, next_cursor }; - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - }; - } - async fn list_threads_common( &self, requested_page_size: usize, @@ -4448,440 +3922,6 @@ impl CodexMessageProcessor { outgoing.send_response(request_id, response).await; } - async fn handle_resume_conversation( - &self, - request_id: ConnectionRequestId, - params: ResumeConversationParams, - ) { - let ResumeConversationParams { - path, - conversation_id, - history, - overrides, - } = params; - - let thread_history = if let Some(path) = path { - match RolloutRecorder::get_rollout_history(&path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to load rollout `{}`: {err}", path.display()), - ) - .await; - return; - } - } - } else if let Some(conversation_id) = conversation_id { - match find_thread_path_by_id_str(&self.config.codex_home, &conversation_id.to_string()) - .await - { - Ok(Some(found_path)) => { - match RolloutRecorder::get_rollout_history(&found_path).await { - Ok(initial_history) => initial_history, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!( - "failed to load rollout `{}` for conversation {conversation_id}: {err}", - found_path.display() - ), - ).await; - return; - } - } - } - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for conversation id {conversation_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate conversation id {conversation_id}: {err}"), - ) - .await; - return; - } - } - } else { - match history { - Some(history) if !history.is_empty() => InitialHistory::Forked( - history.into_iter().map(RolloutItem::ResponseItem).collect(), - ), - Some(_) | None => { - self.send_invalid_request_error( - request_id, - "either path, conversation id or non empty history must be provided" - .to_string(), - ) - .await; - return; - } - } - }; - - let history_cwd = thread_history.session_cwd(); - let (typesafe_overrides, request_overrides) = match overrides { - Some(overrides) => { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: request_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = overrides; - - // Persist Windows sandbox mode. - let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) { - match WindowsSandboxLevel::from_config(&self.config) { - WindowsSandboxLevel::Elevated => { - request_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("elevated"), - ); - } - WindowsSandboxLevel::RestrictedToken => { - request_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("unelevated"), - ); - } - WindowsSandboxLevel::Disabled => {} - } - } - - let typesafe_overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - (typesafe_overrides, Some(request_overrides)) - } - None => ( - ConfigOverrides { - 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() - }, - None, - ), - }; - - let cloud_requirements = self.current_cloud_requirements(); - let config = match derive_config_for_cwd( - &self.cli_overrides, - request_overrides, - typesafe_overrides, - history_cwd, - &cloud_requirements, - ) - .await - { - Ok(cfg) => cfg, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; - return; - } - }; - - match self - .thread_manager - .resume_thread_with_history(config, thread_history, self.auth_manager.clone(), false) - .await - { - Ok(NewThread { - thread_id, - session_configured, - .. - }) => { - let rollout_path = match session_configured.rollout_path.clone() { - Some(path) => path, - None => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "rollout path missing for resumed conversation".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - self.outgoing - .send_server_notification(ServerNotification::SessionConfigured( - SessionConfiguredNotification { - session_id: session_configured.session_id, - model: session_configured.model.clone(), - service_tier: session_configured.service_tier, - reasoning_effort: session_configured.reasoning_effort, - history_log_id: session_configured.history_log_id, - history_entry_count: session_configured.history_entry_count, - initial_messages: session_configured.initial_messages.clone(), - rollout_path: rollout_path.clone(), - }, - )) - .await; - let initial_messages = session_configured - .initial_messages - .map(|msgs| msgs.into_iter().collect()); - - // Reply with thread id + model and initial messages (when present) - let response = ResumeConversationResponse { - conversation_id: thread_id, - model: session_configured.model.clone(), - initial_messages, - rollout_path, - }; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error resuming conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn handle_fork_conversation( - &self, - request_id: ConnectionRequestId, - params: ForkConversationParams, - ) { - let ForkConversationParams { - path, - conversation_id, - overrides, - } = params; - - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let (rollout_path, source_thread_id) = if let Some(path) = path { - (path, None) - } else if let Some(conversation_id) = conversation_id { - match find_thread_path_by_id_str(&self.config.codex_home, &conversation_id.to_string()) - .await - { - Ok(Some(found_path)) => (found_path, Some(conversation_id)), - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for conversation id {conversation_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate conversation id {conversation_id}: {err}"), - ) - .await; - return; - } - } - } else { - self.send_invalid_request_error( - request_id, - "either path or conversation id must be provided".to_string(), - ) - .await; - return; - }; - - let history_cwd = - read_history_cwd_from_state_db(&self.config, source_thread_id, rollout_path.as_path()) - .await; - - let (typesafe_overrides, request_overrides) = match overrides { - Some(overrides) => { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: cli_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = overrides; - - // Persist Windows sandbox mode. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) { - match WindowsSandboxLevel::from_config(&self.config) { - WindowsSandboxLevel::Elevated => { - cli_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("elevated"), - ); - } - WindowsSandboxLevel::RestrictedToken => { - cli_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("unelevated"), - ); - } - WindowsSandboxLevel::Disabled => {} - } - } - let request_overrides = if cli_overrides.is_empty() { - None - } else { - Some(cli_overrides) - }; - - let overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - - (overrides, request_overrides) - } - None => ( - ConfigOverrides { - 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() - }, - None, - ), - }; - - let cloud_requirements = self.current_cloud_requirements(); - let config = match derive_config_for_cwd( - &self.cli_overrides, - request_overrides, - typesafe_overrides, - history_cwd, - &cloud_requirements, - ) - .await - { - Ok(cfg) => cfg, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; - return; - } - }; - - let NewThread { - thread_id, - session_configured, - .. - } = match self - .thread_manager - .fork_thread(usize::MAX, config, rollout_path.clone(), false) - .await - { - Ok(thread) => thread, - Err(err) => { - let (code, message) = match err { - CodexErr::Io(_) | CodexErr::Json(_) => ( - INVALID_REQUEST_ERROR_CODE, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), - ), - CodexErr::InvalidRequest(message) => (INVALID_REQUEST_ERROR_CODE, message), - _ => ( - INTERNAL_ERROR_CODE, - format!("error forking conversation: {err}"), - ), - }; - let error = JSONRPCErrorError { - code, - message, - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let rollout_path = match session_configured.rollout_path.clone() { - Some(path) => path, - None => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "rollout path missing for forked conversation".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - self.outgoing - .send_server_notification(ServerNotification::SessionConfigured( - SessionConfiguredNotification { - session_id: session_configured.session_id, - model: session_configured.model.clone(), - service_tier: session_configured.service_tier, - reasoning_effort: session_configured.reasoning_effort, - history_log_id: session_configured.history_log_id, - history_entry_count: session_configured.history_entry_count, - initial_messages: session_configured.initial_messages.clone(), - rollout_path: rollout_path.clone(), - }, - )) - .await; - let initial_messages = session_configured - .initial_messages - .map(|msgs| msgs.into_iter().collect()); - - // Reply with conversation id + model and initial messages (when present) - let response = ForkConversationResponse { - conversation_id: thread_id, - model: session_configured.model.clone(), - initial_messages, - rollout_path, - }; - self.outgoing.send_response(request_id, response).await; - } - async fn send_invalid_request_error(&self, request_id: ConnectionRequestId, message: String) { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -4905,14 +3945,6 @@ impl CodexMessageProcessor { } } - fn validate_v1_input_limit(items: &[WireInputItem]) -> Result<(), JSONRPCErrorError> { - let actual_chars: usize = items.iter().map(WireInputItem::text_char_count).sum(); - if actual_chars > MAX_USER_INPUT_TEXT_CHARS { - return Err(Self::input_too_large_error(actual_chars)); - } - Ok(()) - } - fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); if actual_chars > MAX_USER_INPUT_TEXT_CHARS { @@ -4930,29 +3962,6 @@ impl CodexMessageProcessor { self.outgoing.send_error(request_id, error).await; } - async fn archive_conversation( - &mut self, - request_id: ConnectionRequestId, - params: ArchiveConversationParams, - ) { - let ArchiveConversationParams { - conversation_id: thread_id, - rollout_path, - } = params; - - match self.archive_thread_common(thread_id, &rollout_path).await { - Ok(()) => { - tracing::info!("thread/archive succeeded for {thread_id}"); - let response = ArchiveConversationResponse {}; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - tracing::warn!("thread/archive failed for {thread_id}: {}", err.message); - self.outgoing.send_error(request_id, err).await; - } - } - } - async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { match thread.submit(Op::Shutdown).await { Ok(_) => { @@ -5215,140 +4224,6 @@ impl CodexMessageProcessor { }) } - async fn send_user_message( - &self, - request_id: ConnectionRequestId, - params: SendUserMessageParams, - app_server_client_name: Option, - ) { - let SendUserMessageParams { - conversation_id, - items, - } = params; - if let Err(error) = Self::validate_v1_input_limit(&items) { - self.outgoing.send_error(request_id, error).await; - return; - } - let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - }; - if let Err(error) = - Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await - { - self.outgoing.send_error(request_id, error).await; - return; - } - - let mapped_items: Vec = items - .into_iter() - .map(|item| match item { - WireInputItem::Text { - text, - text_elements, - } => CoreInputItem::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - WireInputItem::Image { image_url } => CoreInputItem::Image { image_url }, - WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path }, - }) - .collect(); - - // Submit user input to the conversation. - let _ = conversation - .submit(Op::UserInput { - items: mapped_items, - final_output_json_schema: None, - }) - .await; - - // Acknowledge with an empty result. - self.outgoing - .send_response(request_id, SendUserMessageResponse {}) - .await; - } - - async fn send_user_turn( - &self, - request_id: ConnectionRequestId, - params: SendUserTurnParams, - app_server_client_name: Option, - ) { - let SendUserTurnParams { - conversation_id, - items, - cwd, - approval_policy, - sandbox_policy, - model, - service_tier, - effort, - summary, - output_schema, - } = params; - if let Err(error) = Self::validate_v1_input_limit(&items) { - self.outgoing.send_error(request_id, error).await; - return; - } - - let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - }; - if let Err(error) = - Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await - { - self.outgoing.send_error(request_id, error).await; - return; - } - - let mapped_items: Vec = items - .into_iter() - .map(|item| match item { - WireInputItem::Text { - text, - text_elements, - } => CoreInputItem::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - WireInputItem::Image { image_url } => CoreInputItem::Image { image_url }, - WireInputItem::LocalImage { path } => CoreInputItem::LocalImage { path }, - }) - .collect(); - - let _ = conversation - .submit(Op::UserTurn { - items: mapped_items, - cwd, - approval_policy, - sandbox_policy, - model, - effort, - summary: Some(summary), - service_tier, - final_output_json_schema: output_schema, - collaboration_mode: None, - personality: None, - }) - .await; - - self.outgoing - .send_response(request_id, SendUserTurnResponse {}) - .await; - } - async fn apps_list(&self, request_id: ConnectionRequestId, params: AppsListParams) { let mut config = match self.load_latest_config().await { Ok(config) => config, @@ -5803,40 +4678,6 @@ impl CodexMessageProcessor { } } - async fn interrupt_conversation( - &mut self, - request_id: ConnectionRequestId, - params: InterruptConversationParams, - ) { - let InterruptConversationParams { conversation_id } = params; - let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("conversation not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - }; - - let request = request_id.clone(); - - // Record the pending interrupt so we can reply when TurnAborted arrives. - { - let pending_interrupts = self - .thread_state_manager - .thread_state(conversation_id) - .await; - let mut thread_state = pending_interrupts.lock().await; - thread_state - .pending_interrupts - .push((request, ApiVersion::V1)); - } - - // Submit the interrupt; we'll respond upon TurnAborted. - let _ = conversation.submit(Op::Interrupt).await; - } - async fn turn_start( &self, request_id: ConnectionRequestId, @@ -6442,76 +5283,6 @@ impl CodexMessageProcessor { let _ = thread.submit(Op::Interrupt).await; } - async fn add_conversation_listener( - &mut self, - request_id: ConnectionRequestId, - params: AddConversationListenerParams, - ) { - let AddConversationListenerParams { - conversation_id, - experimental_raw_events, - } = params; - let conversation = match self.thread_manager.get_thread(conversation_id).await { - Ok(conv) => conv, - Err(_) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {conversation_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let subscription_id = Uuid::new_v4(); - let thread_state = self - .thread_state_manager - .set_listener( - subscription_id, - conversation_id, - request_id.connection_id, - experimental_raw_events, - ) - .await; - self.ensure_listener_task_running( - conversation_id, - conversation, - thread_state, - ApiVersion::V1, - ) - .await; - - let response = AddConversationSubscriptionResponse { subscription_id }; - self.outgoing.send_response(request_id, response).await; - } - - async fn remove_thread_listener( - &mut self, - request_id: ConnectionRequestId, - params: RemoveConversationListenerParams, - ) { - let RemoveConversationListenerParams { subscription_id } = params; - match self - .thread_state_manager - .remove_listener(subscription_id) - .await - { - Some(thread_id) => { - info!("removed listener for thread {thread_id}"); - let response = RemoveConversationSubscriptionResponse {}; - self.outgoing.send_response(request_id, response).await; - } - None => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("subscription not found: {subscription_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - } - async fn ensure_conversation_listener( &self, conversation_id: ThreadId, @@ -6944,6 +5715,30 @@ impl CodexMessageProcessor { let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); + let sqlite_feedback_logs = if include_logs { + if let Some(log_db) = self.log_db.as_ref() { + log_db.flush().await; + } + let state_db_ctx = get_state_db(&self.config, None).await; + match (state_db_ctx.as_ref(), conversation_id) { + (Some(state_db_ctx), Some(conversation_id)) => { + let thread_id_text = conversation_id.to_string(); + match state_db_ctx.query_feedback_logs(&thread_id_text).await { + Ok(logs) if logs.is_empty() => None, + Ok(logs) => Some(logs), + Err(err) => { + warn!( + "failed to query feedback logs from sqlite for thread_id={thread_id_text}: {err}" + ); + None + } + } + } + _ => None, + } + } else { + None + }; let validated_rollout_path = if include_logs { match conversation_id { @@ -6967,6 +5762,7 @@ impl CodexMessageProcessor { include_logs, &attachment_paths, Some(session_source), + sqlite_feedback_logs, ) }) .await; @@ -8260,143 +7056,6 @@ mod tests { Ok(()) } - #[tokio::test] - async fn removing_listeners_retains_thread_listener_when_last_subscriber_leaves() -> Result<()> - { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let listener_a = Uuid::new_v4(); - let listener_b = Uuid::new_v4(); - let connection_a = ConnectionId(1); - let connection_b = ConnectionId(2); - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - - manager - .set_listener(listener_a, thread_id, connection_a, false) - .await; - manager - .set_listener(listener_b, thread_id, connection_b, false) - .await; - { - let state = manager.thread_state(thread_id).await; - state.lock().await.cancel_tx = Some(cancel_tx); - } - - assert_eq!(manager.remove_listener(listener_a).await, Some(thread_id)); - assert!( - tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) - .await - .is_err() - ); - assert_eq!(manager.remove_listener(listener_b).await, Some(thread_id)); - assert!( - tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) - .await - .is_err() - ); - assert!( - manager - .subscribed_connection_ids(thread_id) - .await - .is_empty() - ); - Ok(()) - } - - #[tokio::test] - async fn removing_listener_unsubscribes_its_connection() -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let listener_a = Uuid::new_v4(); - let listener_b = Uuid::new_v4(); - let connection_a = ConnectionId(1); - let connection_b = ConnectionId(2); - - manager - .set_listener(listener_a, thread_id, connection_a, false) - .await; - manager - .set_listener(listener_b, thread_id, connection_b, false) - .await; - - assert_eq!(manager.remove_listener(listener_a).await, Some(thread_id)); - let subscribed_connection_ids = manager.subscribed_connection_ids(thread_id).await; - assert_eq!(subscribed_connection_ids, vec![connection_b]); - Ok(()) - } - - #[tokio::test] - async fn set_listener_uses_last_write_for_raw_events() -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let listener_a = Uuid::new_v4(); - let listener_b = Uuid::new_v4(); - let connection_a = ConnectionId(1); - let connection_b = ConnectionId(2); - - manager - .set_listener(listener_a, thread_id, connection_a, true) - .await; - { - let state = manager.thread_state(thread_id).await; - assert!(state.lock().await.experimental_raw_events); - } - manager - .set_listener(listener_b, thread_id, connection_b, false) - .await; - let state = manager.thread_state(thread_id).await; - assert!(!state.lock().await.experimental_raw_events); - Ok(()) - } - - #[tokio::test] - async fn removing_connection_retains_listener_and_active_turn_when_last_subscriber_disconnects() - -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let listener = Uuid::new_v4(); - let connection = ConnectionId(1); - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - - manager - .set_listener(listener, thread_id, connection, false) - .await; - { - let state = manager.thread_state(thread_id).await; - let mut state = state.lock().await; - state.cancel_tx = Some(cancel_tx); - state.track_current_turn_event(&EventMsg::TurnStarted( - codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - model_context_window: None, - collaboration_mode_kind: Default::default(), - }, - )); - } - - manager.remove_connection(connection).await; - assert!( - tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) - .await - .is_err() - ); - assert_eq!(manager.remove_listener(listener).await, None); - - let state = manager.thread_state(thread_id).await; - let state = state.lock().await; - assert!( - manager - .subscribed_connection_ids(thread_id) - .await - .is_empty() - ); - assert!(state.cancel_tx.is_some()); - let active_turn = state.active_turn_snapshot().expect("active turn snapshot"); - assert_eq!(active_turn.id, "turn-1"); - assert_eq!(active_turn.status, TurnStatus::InProgress); - Ok(()) - } - #[tokio::test] async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> { let manager = ThreadStateManager::new(); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index a84712ea05..580282d5d3 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -38,16 +38,20 @@ use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::TextRange as CoreTextRange; +use codex_core::features::Feature; use codex_feedback::CodexFeedback; +use codex_state::log_db; use tokio::sync::mpsc; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use toml::Value as TomlValue; +use tracing::Level; use tracing::error; use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; +use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; @@ -477,6 +481,21 @@ pub async fn run_main_with_transport( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); + let log_db = if config.features.enabled(Feature::Sqlite) { + codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + None, + ) + .await + .ok() + .map(log_db::start) + } else { + None + }; + let log_db_layer = log_db + .clone() + .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); @@ -484,6 +503,7 @@ pub async fn run_main_with_transport( .with(stderr_fmt) .with(feedback_layer) .with(feedback_metadata_layer) + .with(log_db_layer) .with(otel_logger_layer) .with(otel_tracing_layer) .try_init(); @@ -562,6 +582,7 @@ pub async fn run_main_with_transport( loader_overrides, cloud_requirements: cloud_requirements.clone(), feedback: feedback.clone(), + log_db, config_warnings, }); let mut thread_created_rx = processor.thread_created_receiver(); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 48f2f69e87..f56e12467f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -54,6 +54,7 @@ use codex_core::models_manager::collaboration_mode_presets::CollaborationModesCo use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; +use codex_state::log_db::LogDbLayer; use futures::FutureExt; use tokio::sync::broadcast; use tokio::sync::watch; @@ -154,6 +155,7 @@ pub(crate) struct MessageProcessorArgs { pub(crate) loader_overrides: LoaderOverrides, pub(crate) cloud_requirements: CloudRequirementsLoader, pub(crate) feedback: CodexFeedback, + pub(crate) log_db: Option, pub(crate) config_warnings: Vec, } @@ -169,6 +171,7 @@ impl MessageProcessor { loader_overrides, cloud_requirements, feedback, + log_db, config_warnings, } = args; let auth_manager = AuthManager::shared( @@ -201,6 +204,7 @@ impl MessageProcessor { cli_overrides: cli_overrides.clone(), cloud_requirements: cloud_requirements.clone(), feedback, + log_db, }); let config_api = ConfigApi::new( config.codex_home.clone(), diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 5f56c81c57..368d5d21a0 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -500,7 +500,6 @@ mod tests { use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::FileChangeRequestApprovalParams; - use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::RateLimitSnapshot; @@ -518,8 +517,8 @@ mod tests { #[test] fn verify_server_notification_serialization() { let notification = - ServerNotification::LoginChatGptComplete(LoginChatGptCompleteNotification { - login_id: Uuid::nil(), + ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { + login_id: Some(Uuid::nil().to_string()), success: true, error: None, }); @@ -527,9 +526,9 @@ mod tests { let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); assert_eq!( json!({ - "method": "loginChatGptComplete", + "method": "account/login/completed", "params": { - "loginId": Uuid::nil(), + "loginId": Uuid::nil().to_string(), "success": true, "error": null, }, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index a60dc07b51..915cc0fd77 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -16,7 +16,6 @@ use std::sync::Weak; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; -use uuid::Uuid; type PendingInterruptQueue = Vec<( ConnectionRequestId, @@ -116,12 +115,6 @@ impl ThreadState { } } -#[derive(Clone, Copy)] -struct SubscriptionState { - thread_id: ThreadId, - connection_id: ConnectionId, -} - struct ThreadEntry { state: Arc>, connection_ids: HashSet, @@ -140,7 +133,6 @@ impl Default for ThreadEntry { struct ThreadStateManagerInner { live_connections: HashSet, threads: HashMap, - subscription_state_by_id: HashMap, thread_ids_by_connection: HashMap>, } @@ -176,70 +168,6 @@ impl ThreadStateManager { state.threads.entry(thread_id).or_default().state.clone() } - pub(crate) async fn remove_listener(&self, subscription_id: Uuid) -> Option { - let (subscription_state, connection_still_subscribed_to_thread, thread_state) = { - let mut state = self.state.lock().await; - let subscription_state = state.subscription_state_by_id.remove(&subscription_id)?; - let thread_id = subscription_state.thread_id; - - let connection_still_subscribed_to_thread = state - .subscription_state_by_id - .values() - .any(|subscription_state_entry| { - subscription_state_entry.thread_id == thread_id - && subscription_state_entry.connection_id - == subscription_state.connection_id - }); - if !connection_still_subscribed_to_thread { - let mut remove_connection_entry = false; - if let Some(thread_ids) = state - .thread_ids_by_connection - .get_mut(&subscription_state.connection_id) - { - thread_ids.remove(&thread_id); - remove_connection_entry = thread_ids.is_empty(); - } - if remove_connection_entry { - state - .thread_ids_by_connection - .remove(&subscription_state.connection_id); - } - if let Some(thread_entry) = state.threads.get_mut(&thread_id) { - thread_entry - .connection_ids - .remove(&subscription_state.connection_id); - } - } - - let thread_state = state.threads.get(&thread_id).map(|thread_entry| { - ( - thread_entry.connection_ids.is_empty(), - thread_entry.state.clone(), - ) - }); - ( - subscription_state, - connection_still_subscribed_to_thread, - thread_state, - ) - }; - let thread_id = subscription_state.thread_id; - - if let Some((no_subscribers, thread_state)) = thread_state { - let thread_state = thread_state.lock().await; - if !connection_still_subscribed_to_thread && no_subscribers { - tracing::debug!( - thread_id = %thread_id, - subscription_id = %subscription_id, - connection_id = ?subscription_state.connection_id, - listener_generation = thread_state.listener_generation, - "retaining thread listener after last subscription removed" - ); - } - } - Some(thread_id) - } - pub(crate) async fn remove_thread_state(&self, thread_id: ThreadId) { let thread_state = { let mut state = self.state.lock().await; @@ -247,9 +175,6 @@ impl ThreadStateManager { .threads .remove(&thread_id) .map(|thread_entry| thread_entry.state); - state - .subscription_state_by_id - .retain(|_, state| state.thread_id != thread_id); state.thread_ids_by_connection.retain(|_, thread_ids| { thread_ids.remove(&thread_id); !thread_ids.is_empty() @@ -298,13 +223,6 @@ impl ThreadStateManager { if let Some(thread_entry) = state.threads.get_mut(&thread_id) { thread_entry.connection_ids.remove(&connection_id); } - - state - .subscription_state_by_id - .retain(|_, subscription_state| { - !(subscription_state.thread_id == thread_id - && subscription_state.connection_id == connection_id) - }); }; true @@ -319,38 +237,6 @@ impl ThreadStateManager { .is_some_and(|thread_entry| !thread_entry.connection_ids.is_empty()) } - pub(crate) async fn set_listener( - &self, - subscription_id: Uuid, - thread_id: ThreadId, - connection_id: ConnectionId, - experimental_raw_events: bool, - ) -> Arc> { - let thread_state = { - let mut state = self.state.lock().await; - state.subscription_state_by_id.insert( - subscription_id, - SubscriptionState { - thread_id, - connection_id, - }, - ); - state - .thread_ids_by_connection - .entry(connection_id) - .or_default() - .insert(thread_id); - let thread_entry = state.threads.entry(thread_id).or_default(); - thread_entry.connection_ids.insert(connection_id); - thread_entry.state.clone() - }; - { - let mut thread_state_guard = thread_state.lock().await; - thread_state_guard.set_experimental_raw_events(experimental_raw_events); - } - thread_state - } - pub(crate) async fn try_ensure_connection_subscribed( &self, thread_id: ThreadId, @@ -411,9 +297,6 @@ impl ThreadStateManager { .thread_ids_by_connection .remove(&connection_id) .unwrap_or_default(); - state - .subscription_state_by_id - .retain(|_, state| state.connection_id != connection_id); for thread_id in &thread_ids { if let Some(thread_entry) = state.threads.get_mut(thread_id) { thread_entry.connection_ids.remove(&connection_id); diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 9f0fb31cb4..4b525878b8 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -11,11 +11,8 @@ use tokio::process::ChildStdin; use tokio::process::ChildStdout; use anyhow::Context; -use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AppsListParams; -use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::CancelLoginAccountParams; -use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::CollaborationModeListParams; @@ -24,32 +21,23 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::FeedbackUploadParams; -use codex_app_server_protocol::ForkConversationParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; -use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::LoginAccountParams; -use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ResumeConversationParams; use codex_app_server_protocol::ReviewStartParams; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserTurnParams; use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; @@ -243,71 +231,6 @@ impl McpProcess { } } - /// Send a `newConversation` JSON-RPC request. - pub async fn send_new_conversation_request( - &mut self, - params: NewConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("newConversation", params).await - } - - /// Send an `archiveConversation` JSON-RPC request. - pub async fn send_archive_conversation_request( - &mut self, - params: ArchiveConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("archiveConversation", params).await - } - - /// Send an `addConversationListener` JSON-RPC request. - pub async fn send_add_conversation_listener_request( - &mut self, - params: AddConversationListenerParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("addConversationListener", params).await - } - - /// Send a `sendUserMessage` JSON-RPC request with a single text item. - pub async fn send_send_user_message_request( - &mut self, - params: SendUserMessageParams, - ) -> anyhow::Result { - // Wire format expects variants in camelCase; text item uses external tagging. - let params = Some(serde_json::to_value(params)?); - self.send_request("sendUserMessage", params).await - } - - /// Send a `removeConversationListener` JSON-RPC request. - pub async fn send_remove_thread_listener_request( - &mut self, - params: RemoveConversationListenerParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("removeConversationListener", params) - .await - } - - /// Send a `sendUserTurn` JSON-RPC request. - pub async fn send_send_user_turn_request( - &mut self, - params: SendUserTurnParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("sendUserTurn", params).await - } - - /// Send a `interruptConversation` JSON-RPC request. - pub async fn send_interrupt_conversation_request( - &mut self, - params: InterruptConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("interruptConversation", params).await - } - /// Send a `getAuthStatus` JSON-RPC request. pub async fn send_get_auth_status_request( &mut self, @@ -317,14 +240,13 @@ impl McpProcess { self.send_request("getAuthStatus", params).await } - /// Send a `getUserSavedConfig` JSON-RPC request. - pub async fn send_get_user_saved_config_request(&mut self) -> anyhow::Result { - self.send_request("getUserSavedConfig", None).await - } - - /// Send a `getUserAgent` JSON-RPC request. - pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result { - self.send_request("getUserAgent", None).await + /// Send a `getConversationSummary` JSON-RPC request. + pub async fn send_get_conversation_summary_request( + &mut self, + params: GetConversationSummaryParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("getConversationSummary", params).await } /// Send an `account/rateLimits/read` JSON-RPC request. @@ -366,29 +288,6 @@ impl McpProcess { self.send_request("feedback/upload", params).await } - /// Send a `userInfo` JSON-RPC request. - pub async fn send_user_info_request(&mut self) -> anyhow::Result { - self.send_request("userInfo", None).await - } - - /// Send a `setDefaultModel` JSON-RPC request. - pub async fn send_set_default_model_request( - &mut self, - params: SetDefaultModelParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("setDefaultModel", params).await - } - - /// Send a `listConversations` JSON-RPC request. - pub async fn send_list_conversations_request( - &mut self, - params: ListConversationsParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("listConversations", params).await - } - /// Send a `thread/start` JSON-RPC request. pub async fn send_thread_start_request( &mut self, @@ -548,38 +447,6 @@ impl McpProcess { self.send_request("mock/experimentalMethod", params).await } - /// Send a `resumeConversation` JSON-RPC request. - pub async fn send_resume_conversation_request( - &mut self, - params: ResumeConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("resumeConversation", params).await - } - - /// Send a `forkConversation` JSON-RPC request. - pub async fn send_fork_conversation_request( - &mut self, - params: ForkConversationParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("forkConversation", params).await - } - - /// Send a `loginApiKey` JSON-RPC request. - pub async fn send_login_api_key_request( - &mut self, - params: LoginApiKeyParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("loginApiKey", params).await - } - - /// Send a `loginChatGpt` JSON-RPC request. - pub async fn send_login_chat_gpt_request(&mut self) -> anyhow::Result { - self.send_request("loginChatGpt", None).await - } - /// Send a `turn/start` JSON-RPC request (v2). pub async fn send_turn_start_request( &mut self, @@ -719,20 +586,6 @@ impl McpProcess { self.send_request("windowsSandbox/setupStart", params).await } - /// Send a `cancelLoginChatGpt` JSON-RPC request. - pub async fn send_cancel_login_chat_gpt_request( - &mut self, - params: CancelLoginChatGptParams, - ) -> anyhow::Result { - let params = Some(serde_json::to_value(params)?); - self.send_request("cancelLoginChatGpt", params).await - } - - /// Send a `logoutChatGpt` JSON-RPC request. - pub async fn send_logout_chat_gpt_request(&mut self) -> anyhow::Result { - self.send_request("logoutChatGpt", None).await - } - pub async fn send_config_read_request( &mut self, params: ConfigReadParams, diff --git a/codex-rs/app-server/tests/suite/archive_thread.rs b/codex-rs/app-server/tests/suite/archive_thread.rs deleted file mode 100644 index a47b247bfd..0000000000 --- a/codex-rs/app-server/tests/suite/archive_thread.rs +++ /dev/null @@ -1,154 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_mock_responses_server_repeating_assistant; -use app_test_support::to_response; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::ArchiveConversationParams; -use codex_app_server_protocol::ArchiveConversationResponse; -use codex_app_server_protocol::InputItem; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use codex_core::ARCHIVED_SESSIONS_SUBDIR; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("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_READ_TIMEOUT, mcp.initialize()).await??; - - let new_request_id = mcp - .send_new_conversation_request(NewConversationParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let new_response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_request_id)), - ) - .await??; - - let NewConversationResponse { - conversation_id, - rollout_path, - .. - } = to_response::(new_response)?; - - assert!( - !rollout_path.exists(), - "expected rollout path {} to be deferred until first user message", - rollout_path.display() - ); - - let add_listener_request_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let add_listener_response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_request_id)), - ) - .await??; - let AddConversationSubscriptionResponse { subscription_id: _ } = - to_response::(add_listener_response)?; - - let send_request_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![InputItem::Text { - text: "materialize".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - let send_response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_request_id)), - ) - .await??; - let _: SendUserMessageResponse = to_response::(send_response)?; - let _: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - assert!( - rollout_path.exists(), - "expected rollout path {} to exist after first user message", - rollout_path.display() - ); - - let archive_request_id = mcp - .send_archive_conversation_request(ArchiveConversationParams { - conversation_id, - rollout_path: rollout_path.clone(), - }) - .await?; - let archive_response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(archive_request_id)), - ) - .await??; - - let _: ArchiveConversationResponse = - to_response::(archive_response)?; - - let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); - let archived_rollout_path = - archived_directory.join(rollout_path.file_name().unwrap_or_else(|| { - panic!("rollout path {} missing file name", rollout_path.display()) - })); - - assert!( - !rollout_path.exists(), - "expected rollout path {} to be moved", - rollout_path.display() - ); - assert!( - archived_rollout_path.exists(), - "expected archived rollout path {} to exist", - archived_rollout_path.display() - ); - - Ok(()) -} - -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write(config_toml, config_contents(server_uri)) -} - -fn config_contents(server_uri: &str) -> String { - format!( - r#"model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ) -} diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 4693158611..2bbdeff400 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -6,8 +6,7 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::LoginApiKeyParams; -use codex_app_server_protocol::LoginApiKeyResponse; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; use std::path::Path; @@ -72,18 +71,15 @@ forced_login_method = "{forced_method}" } async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) -> Result<()> { - let request_id = mcp - .send_login_api_key_request(LoginApiKeyParams { - api_key: api_key.to_string(), - }) - .await?; + let request_id = mcp.send_login_account_api_key_request(api_key).await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - let _: LoginApiKeyResponse = to_response(resp)?; + let response: LoginAccountResponse = to_response(resp)?; + assert_eq!(response, LoginAccountResponse::ApiKey {}); Ok(()) } @@ -211,9 +207,7 @@ async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_login_api_key_request(LoginApiKeyParams { - api_key: "sk-test-key".to_string(), - }) + .send_login_account_api_key_request("sk-test-key") .await?; let err: JSONRPCError = timeout( diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs deleted file mode 100644 index b5176886bd..0000000000 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ /dev/null @@ -1,550 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_final_assistant_message_sse_response; -use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_shell_command_sse_response; -use app_test_support::format_with_current_shell; -use app_test_support::to_response; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::ExecCommandApprovalParams; -use codex_app_server_protocol::InputItem; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RemoveConversationListenerParams; -use codex_app_server_protocol::RemoveConversationSubscriptionResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use codex_app_server_protocol::SendUserTurnParams; -use codex_app_server_protocol::SendUserTurnResponse; -use codex_app_server_protocol::ServerRequest; -use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::Event; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::SandboxPolicy; -use pretty_assertions::assert_eq; -use std::env; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45); - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { - if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return Ok(()); - } - - let tmp = TempDir::new()?; - // Temporary Codex home with config pointing at the mock server. - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory)?; - - // Create a mock model server that immediately ends each turn. - // Two turns are expected: initial session configure + one user message. - let responses = vec![ - create_shell_command_sse_response( - vec!["ls".to_string()], - Some(&working_directory), - Some(5000), - "call1234", - )?, - create_final_assistant_message_sse_response("Enjoy your new git repo!")?, - ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml(&codex_home, &server.uri())?; - - // Start MCP server and initialize. - let mut mcp = McpProcess::new(&codex_home).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // 1) newConversation - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - cwd: Some(working_directory.to_string_lossy().into_owned()), - sandbox: Some(SandboxMode::DangerFullAccess), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let new_conv_resp = to_response::(new_conv_resp)?; - let NewConversationResponse { - conversation_id, - model, - reasoning_effort: _, - rollout_path: _, - } = new_conv_resp; - assert_eq!(model, "mock-model"); - - // 2) addConversationListener - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let add_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - let AddConversationSubscriptionResponse { subscription_id } = - to_response::(add_listener_resp)?; - - // Drop any buffered events from conversation setup to avoid - // matching an earlier task_complete. - mcp.clear_message_buffer(); - - // 3) sendUserMessage (should trigger notifications; we only validate an OK response) - let send_user_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![codex_app_server_protocol::InputItem::Text { - text: "text".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - let send_user_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)), - ) - .await??; - let SendUserMessageResponse {} = to_response::(send_user_resp)?; - - let task_started_notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_started"), - ) - .await??; - let task_started_event: Event = serde_json::from_value( - task_started_notification - .params - .clone() - .expect("task_started should have params"), - ) - .expect("task_started should deserialize to Event"); - - // Verify the task_finished notification for this turn is received. - // Note this also ensures that the final request to the server was made. - let task_finished_notification: JSONRPCNotification = loop { - let notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - let event: Event = serde_json::from_value( - notification - .params - .clone() - .expect("task_complete should have params"), - ) - .expect("task_complete should deserialize to Event"); - if event.id == task_started_event.id { - break notification; - } - }; - let serde_json::Value::Object(map) = task_finished_notification - .params - .expect("notification should have params") - else { - panic!("task_finished_notification should have params"); - }; - assert_eq!( - map.get("conversationId") - .expect("should have conversationId"), - &serde_json::Value::String(conversation_id.to_string()) - ); - - // 4) removeConversationListener - let remove_listener_id = mcp - .send_remove_thread_listener_request(RemoveConversationListenerParams { subscription_id }) - .await?; - let remove_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(remove_listener_id)), - ) - .await??; - let RemoveConversationSubscriptionResponse {} = to_response(remove_listener_resp)?; - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { - if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return Ok(()); - } - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory)?; - - // Mock server will request a python shell call for the first and second turn, then finish. - let responses = vec![ - create_shell_command_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], - Some(&working_directory), - Some(5000), - "call1", - )?, - create_final_assistant_message_sse_response("done 1")?, - create_shell_command_sse_response( - vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], - Some(&working_directory), - Some(5000), - "call2", - )?, - create_final_assistant_message_sse_response("done 2")?, - ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml(&codex_home, &server.uri())?; - - // Start MCP server and initialize. - let mut mcp = McpProcess::new(&codex_home).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // 1) Start conversation with approval_policy=untrusted - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - cwd: Some(working_directory.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::(new_conv_resp)?; - - // 2) addConversationListener - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let _: AddConversationSubscriptionResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??, - )?; - - // 3) sendUserMessage triggers a shell call; approval policy is Untrusted so we should get an elicitation - let send_user_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![codex_app_server_protocol::InputItem::Text { - text: "run python".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - let _send_user_resp: SendUserMessageResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)), - ) - .await??, - )?; - - // Expect an ExecCommandApproval request (elicitation) - let request = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_request_message(), - ) - .await??; - let ServerRequest::ExecCommandApproval { request_id, params } = request else { - panic!("expected ExecCommandApproval request, got: {request:?}"); - }; - - assert_eq!( - ExecCommandApprovalParams { - conversation_id, - call_id: "call1".to_string(), - approval_id: None, - command: format_with_current_shell("python3 -c 'print(42)'"), - cwd: working_directory.clone(), - reason: None, - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "python3 -c 'print(42)'".to_string() - }], - }, - params - ); - - // Approve so the first turn can complete - mcp.send_response( - request_id, - serde_json::json!({ "decision": codex_protocol::protocol::ReviewDecision::Approved }), - ) - .await?; - - // Wait for first TurnComplete - let _ = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - // 4) sendUserTurn with approval_policy=never should run without elicitation - let send_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![codex_app_server_protocol::InputItem::Text { - text: "run python again".to_string(), - text_elements: Vec::new(), - }], - cwd: working_directory.clone(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: "mock-model".to_string(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: None, - }) - .await?; - // Acknowledge sendUserTurn - let _send_turn_resp: SendUserTurnResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)), - ) - .await??, - )?; - - // Ensure we do NOT receive an ExecCommandApproval request before the task completes. - // If any Request is seen while waiting for task_complete, the helper will error and the test fails. - let _ = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - Ok(()) -} - -// Helper: minimal config.toml pointing at mock provider. - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<()> { - if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return Ok(()); - } - - let tmp = TempDir::new()?; - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let workspace_root = tmp.path().join("workspace"); - std::fs::create_dir(&workspace_root)?; - let first_cwd = workspace_root.join("turn1"); - let second_cwd = workspace_root.join("turn2"); - std::fs::create_dir(&first_cwd)?; - std::fs::create_dir(&second_cwd)?; - - let responses = vec![ - create_shell_command_sse_response( - vec!["echo".to_string(), "first".to_string(), "turn".to_string()], - None, - Some(5000), - "call-first", - )?, - create_final_assistant_message_sse_response("done first")?, - create_shell_command_sse_response( - vec!["echo".to_string(), "second".to_string(), "turn".to_string()], - None, - Some(5000), - "call-second", - )?, - create_final_assistant_message_sse_response("done second")?, - ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml(&codex_home, &server.uri())?; - - let mut mcp = McpProcess::new(&codex_home).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - cwd: Some(first_cwd.to_string_lossy().into_owned()), - approval_policy: Some(AskForApproval::Never), - sandbox: Some(SandboxMode::WorkspaceWrite), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, - model, - .. - } = to_response::(new_conv_resp)?; - - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - - let first_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: "first turn".to_string(), - text_elements: Vec::new(), - }], - cwd: first_cwd.clone(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::WorkspaceWrite { - writable_roots: vec![first_cwd.try_into()?], - read_only_access: Default::default(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - model: model.clone(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: None, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - mcp.clear_message_buffer(); - - let second_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: "second turn".to_string(), - text_elements: Vec::new(), - }], - cwd: second_cwd.clone(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - model: model.clone(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: None, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), - ) - .await??; - - let exec_begin_notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/exec_command_begin"), - ) - .await??; - let params = exec_begin_notification - .params - .clone() - .expect("exec_command_begin params"); - let event: Event = serde_json::from_value(params).expect("deserialize exec begin event"); - let exec_begin = match event.msg { - EventMsg::ExecCommandBegin(exec_begin) => exec_begin, - other => panic!("expected ExecCommandBegin event, got {other:?}"), - }; - assert_eq!( - exec_begin.cwd, second_cwd, - "exec turn should run from updated cwd" - ); - let expected_command = format_with_current_shell("echo second turn"); - assert_eq!( - exec_begin.command, expected_command, - "exec turn should run expected command" - ); - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - Ok(()) -} - -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "untrusted" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs deleted file mode 100644 index 27a44ef550..0000000000 --- a/codex-rs/app-server/tests/suite/config.rs +++ /dev/null @@ -1,158 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::test_tmp_path; -use app_test_support::to_response; -use codex_app_server_protocol::GetUserSavedConfigResponse; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::Profile; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SandboxSettings; -use codex_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; -use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode; -use codex_protocol::config_types::Verbosity; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; -use pretty_assertions::assert_eq; -use std::collections::HashMap; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { - let writable_root = test_tmp_path(); - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "gpt-5.1-codex-max" -approval_policy = "on-request" -sandbox_mode = "workspace-write" -model_reasoning_summary = "detailed" -model_reasoning_effort = "high" -model_verbosity = "medium" -profile = "test" -forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000" -forced_login_method = "chatgpt" - -[sandbox_workspace_write] -writable_roots = [{}] -network_access = true -exclude_tmpdir_env_var = true -exclude_slash_tmp = true - -[tools] -web_search = false -view_image = true - -[profiles.test] -model = "gpt-4o" -approval_policy = "on-request" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" -model_verbosity = "medium" -model_provider = "openai" -chatgpt_base_url = "https://api.chatgpt.com" -"#, - serde_json::json!(writable_root) - ), - ) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn get_config_toml_parses_all_fields() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_get_user_saved_config_request().await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let config: GetUserSavedConfigResponse = to_response(resp)?; - let writable_root = test_tmp_path(); - let expected = GetUserSavedConfigResponse { - config: UserSavedConfig { - approval_policy: Some(AskForApproval::OnRequest), - sandbox_mode: Some(SandboxMode::WorkspaceWrite), - sandbox_settings: Some(SandboxSettings { - writable_roots: vec![writable_root], - network_access: Some(true), - exclude_tmpdir_env_var: Some(true), - exclude_slash_tmp: Some(true), - }), - forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()), - forced_login_method: Some(ForcedLoginMethod::Chatgpt), - model: Some("gpt-5.1-codex-max".into()), - model_reasoning_effort: Some(ReasoningEffort::High), - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_verbosity: Some(Verbosity::Medium), - tools: Some(Tools { - web_search: Some(false), - view_image: Some(true), - }), - profile: Some("test".to_string()), - profiles: HashMap::from([( - "test".into(), - Profile { - model: Some("gpt-4o".into()), - approval_policy: Some(AskForApproval::OnRequest), - model_reasoning_effort: Some(ReasoningEffort::High), - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_verbosity: Some(Verbosity::Medium), - model_provider: Some("openai".into()), - chatgpt_base_url: Some("https://api.chatgpt.com".into()), - }, - )]), - }, - }; - - assert_eq!(config, expected); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_config_toml_empty() -> Result<()> { - let codex_home = TempDir::new()?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_get_user_saved_config_request().await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let config: GetUserSavedConfigResponse = to_response(resp)?; - let expected = GetUserSavedConfigResponse { - config: UserSavedConfig { - approval_policy: None, - sandbox_mode: None, - sandbox_settings: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - model: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - tools: None, - profile: None, - profiles: HashMap::new(), - }, - }; - - assert_eq!(config, expected); - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs new file mode 100644 index 0000000000..31108a8455 --- /dev/null +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -0,0 +1,113 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::rollout_path; +use app_test_support::to_response; +use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use pretty_assertions::assert_eq; +use std::path::PathBuf; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const FILENAME_TS: &str = "2025-01-02T12-00-00"; +const META_RFC3339: &str = "2025-01-02T12:00:00Z"; +const PREVIEW: &str = "Summarize this conversation"; +const MODEL_PROVIDER: &str = "openai"; + +fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary { + ConversationSummary { + conversation_id, + path, + preview: PREVIEW.to_string(), + timestamp: Some(META_RFC3339.to_string()), + updated_at: Some(META_RFC3339.to_string()), + model_provider: MODEL_PROVIDER.to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + git_info: None, + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { + let codex_home = TempDir::new()?; + let conversation_id = create_fake_rollout( + codex_home.path(), + FILENAME_TS, + META_RFC3339, + PREVIEW, + Some(MODEL_PROVIDER), + None, + )?; + let thread_id = ThreadId::from_string(&conversation_id)?; + let expected = expected_summary( + thread_id, + std::fs::canonicalize(rollout_path( + codex_home.path(), + FILENAME_TS, + &conversation_id, + ))?, + ); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_conversation_summary_request(GetConversationSummaryParams::ThreadId { + conversation_id: thread_id, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetConversationSummaryResponse = to_response(response)?; + + assert_eq!(received.summary, expected); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_home() -> Result<()> +{ + let codex_home = TempDir::new()?; + let conversation_id = create_fake_rollout( + codex_home.path(), + FILENAME_TS, + META_RFC3339, + PREVIEW, + Some(MODEL_PROVIDER), + None, + )?; + let thread_id = ThreadId::from_string(&conversation_id)?; + let rollout_path = rollout_path(codex_home.path(), FILENAME_TS, &conversation_id); + let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf(); + let expected = expected_summary(thread_id, std::fs::canonicalize(rollout_path)?); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_conversation_summary_request(GetConversationSummaryParams::RolloutPath { + rollout_path: relative_path, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetConversationSummaryResponse = to_response(response)?; + + assert_eq!(received.summary, expected); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/create_thread.rs b/codex-rs/app-server/tests/suite/create_thread.rs deleted file mode 100644 index 8ad3342539..0000000000 --- a/codex-rs/app-server/tests/suite/create_thread.rs +++ /dev/null @@ -1,142 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_final_assistant_message_sse_response; -use app_test_support::to_response; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::InputItem; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use core_test_support::responses; -use pretty_assertions::assert_eq; -use serde_json::json; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_conversation_create_and_send_message_ok() -> Result<()> { - // Mock server – we won't strictly rely on it, but provide one to satisfy any model wiring. - let response_body = create_final_assistant_message_sse_response("Done")?; - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence(&server, vec![response_body]).await; - - // Temporary Codex home with config pointing at the mock server. - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - // Start MCP server process and initialize. - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // Create a conversation via the new JSON-RPC API. - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - model: Some("o3".to_string()), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, - model, - reasoning_effort: _, - rollout_path: _, - } = to_response::(new_conv_resp)?; - assert_eq!(model, "o3"); - - // Add a listener so we receive notifications for this conversation (not strictly required for this test). - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let _sub: AddConversationSubscriptionResponse = - to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??, - )?; - - // Now send a user message via the wire API and expect an OK (empty object) result. - let send_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![InputItem::Text { - text: "Hello".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - let send_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_id)), - ) - .await??; - let _ok: SendUserMessageResponse = to_response::(send_resp)?; - - // Avoid race condition by waiting for the mock server to receive the responses request. - let deadline = std::time::Instant::now() + DEFAULT_READ_TIMEOUT; - let requests = loop { - let requests = response_mock.requests(); - if !requests.is_empty() { - break requests; - } - if std::time::Instant::now() >= deadline { - panic!("mock server did not receive the responses request in time"); - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - }; - - // Verify the outbound request body matches expectations for Responses. - let request = requests - .first() - .expect("mock server should have received at least one request"); - let body = request.body_json(); - assert_eq!(body["model"], json!("o3")); - let user_texts = request.message_input_texts("user"); - assert!( - user_texts.iter().any(|text| text == "Hello"), - "expected user input to include Hello, got {user_texts:?}" - ); - - drop(server); - Ok(()) -} - -// Helper to create a config.toml pointing at the mock model server. -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/app-server/tests/suite/fork_thread.rs b/codex-rs/app-server/tests/suite/fork_thread.rs deleted file mode 100644 index ec282be4a1..0000000000 --- a/codex-rs/app-server/tests/suite/fork_thread.rs +++ /dev/null @@ -1,140 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_fake_rollout; -use app_test_support::to_response; -use codex_app_server_protocol::ForkConversationParams; -use codex_app_server_protocol::ForkConversationResponse; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; // reused for overrides shape -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::SessionConfiguredNotification; -use codex_protocol::protocol::EventMsg; -use pretty_assertions::assert_eq; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn fork_conversation_creates_new_rollout() -> Result<()> { - let codex_home = TempDir::new()?; - - let preview = "Hello A"; - let conversation_id = create_fake_rollout( - codex_home.path(), - "2025-01-02T12-00-00", - "2025-01-02T12:00:00Z", - preview, - Some("openai"), - None, - )?; - - let original_path = codex_home - .path() - .join("sessions") - .join("2025") - .join("01") - .join("02") - .join(format!( - "rollout-2025-01-02T12-00-00-{conversation_id}.jsonl" - )); - assert!( - original_path.exists(), - "expected original rollout to exist at {}", - original_path.display() - ); - let original_contents = std::fs::read_to_string(&original_path)?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let fork_req_id = mcp - .send_fork_conversation_request(ForkConversationParams { - path: Some(original_path.clone()), - conversation_id: None, - overrides: Some(NewConversationParams { - model: Some("o3".to_string()), - ..Default::default() - }), - }) - .await?; - - // Expect a sessionConfigured notification for the forked session. - let notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("sessionConfigured"), - ) - .await??; - let session_configured: ServerNotification = notification.try_into()?; - let ServerNotification::SessionConfigured(SessionConfiguredNotification { - model, - session_id, - rollout_path, - initial_messages: session_initial_messages, - .. - }) = session_configured - else { - unreachable!("expected sessionConfigured notification"); - }; - - assert_eq!(model, "o3"); - assert_ne!( - session_id.to_string(), - conversation_id, - "expected a new conversation id when forking" - ); - assert_ne!( - rollout_path, original_path, - "expected a new rollout path when forking" - ); - assert!( - rollout_path.exists(), - "expected forked rollout to exist at {}", - rollout_path.display() - ); - - let session_initial_messages = - session_initial_messages.expect("expected initial messages when forking from rollout"); - match session_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, preview); - } - other => panic!("unexpected initial messages from rollout fork: {other:#?}"), - } - - // Then the response for forkConversation. - let fork_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(fork_req_id)), - ) - .await??; - let ForkConversationResponse { - conversation_id: forked_id, - model: forked_model, - initial_messages: response_initial_messages, - rollout_path: response_rollout_path, - } = to_response::(fork_resp)?; - - assert_eq!(forked_model, "o3"); - assert_eq!(response_rollout_path, rollout_path); - assert_ne!(forked_id.to_string(), conversation_id); - - let response_initial_messages = - response_initial_messages.expect("expected initial messages in fork response"); - match response_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, preview); - } - other => panic!("unexpected initial messages in fork response: {other:#?}"), - } - - let after_contents = std::fs::read_to_string(&original_path)?; - assert_eq!( - after_contents, original_contents, - "fork should not mutate the original rollout file" - ); - - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs deleted file mode 100644 index dd09b832f8..0000000000 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ /dev/null @@ -1,163 +0,0 @@ -#![cfg(unix)] -// Support code lives in the `app_test_support` crate under tests/common. - -use std::path::Path; - -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::InterruptConversationParams; -use codex_app_server_protocol::InterruptConversationResponse; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use codex_protocol::protocol::TurnAbortReason; -use core_test_support::skip_if_no_network; -use tempfile::TempDir; -use tokio::time::timeout; - -use app_test_support::McpProcess; -use app_test_support::create_mock_responses_server_sequence; -use app_test_support::create_shell_command_sse_response; -use app_test_support::to_response; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_shell_command_interruption() { - skip_if_no_network!(); - - if let Err(err) = shell_command_interruption().await { - panic!("failure: {err}"); - } -} - -async fn shell_command_interruption() -> anyhow::Result<()> { - // Use a cross-platform blocking command. On Windows plain `sleep` is not guaranteed to exist - // (MSYS/GNU coreutils may be absent) and the failure causes the tool call to finish immediately, - // which triggers a second model request before the test sends the explicit follow-up. That - // prematurely consumes the second mocked SSE response and leads to a third POST (panic: no response for 2). - // Powershell Start-Sleep is always available on Windows runners. On Unix we keep using `sleep`. - #[cfg(target_os = "windows")] - let shell_command = vec![ - "powershell".to_string(), - "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), - ]; - #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; - - let tmp = TempDir::new()?; - // Temporary Codex home with config pointing at the mock server. - let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home)?; - let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory)?; - - // Create mock server with a single SSE response: the long sleep command - let server = create_mock_responses_server_sequence(vec![create_shell_command_sse_response( - shell_command.clone(), - Some(&working_directory), - Some(10_000), // 10 seconds timeout in ms - "call_sleep", - )?]) - .await; - create_config_toml(&codex_home, server.uri())?; - - // Start MCP server and initialize. - let mut mcp = McpProcess::new(&codex_home).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // 1) newConversation - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - cwd: Some(working_directory.to_string_lossy().into_owned()), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let new_conv_resp = to_response::(new_conv_resp)?; - let NewConversationResponse { - conversation_id, .. - } = new_conv_resp; - - // 2) addConversationListener - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let _add_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - - // 3) sendUserMessage (should trigger notifications; we only validate an OK response) - let send_user_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![codex_app_server_protocol::InputItem::Text { - text: "run first sleep command".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - let send_user_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)), - ) - .await??; - let SendUserMessageResponse {} = to_response::(send_user_resp)?; - - // Give the command a moment to start - tokio::time::sleep(std::time::Duration::from_secs(1)).await; - - // 4) send interrupt request - let interrupt_id = mcp - .send_interrupt_conversation_request(InterruptConversationParams { conversation_id }) - .await?; - let interrupt_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)), - ) - .await??; - let InterruptConversationResponse { abort_reason } = - to_response::(interrupt_resp)?; - assert_eq!(TurnAbortReason::Interrupted, abort_reason); - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs deleted file mode 100644 index 97c8f41d11..0000000000 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ /dev/null @@ -1,440 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_fake_rollout; -use app_test_support::to_response; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ListConversationsParams; -use codex_app_server_protocol::ListConversationsResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ResumeConversationParams; -use codex_app_server_protocol::ResumeConversationResponse; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::SessionConfiguredNotification; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::EventMsg; -use pretty_assertions::assert_eq; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_list_and_resume_conversations() -> Result<()> { - // Prepare a temporary CODEX_HOME with a few fake rollout files. - let codex_home = TempDir::new()?; - create_fake_rollout( - codex_home.path(), - "2025-01-02T12-00-00", - "2025-01-02T12:00:00Z", - "Hello A", - Some("openai"), - None, - )?; - create_fake_rollout( - codex_home.path(), - "2025-01-01T13-00-00", - "2025-01-01T13:00:00Z", - "Hello B", - Some("openai"), - None, - )?; - create_fake_rollout( - codex_home.path(), - "2025-01-01T12-00-00", - "2025-01-01T12:00:00Z", - "Hello C", - None, - None, - )?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // Request first page with size 2 - let req_id = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(2), - cursor: None, - model_providers: None, - }) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(req_id)), - ) - .await??; - let ListConversationsResponse { items, next_cursor } = - to_response::(resp)?; - - assert_eq!(items.len(), 2); - // Newest first; preview text should match - assert_eq!(items[0].preview, "Hello A"); - assert_eq!(items[1].preview, "Hello B"); - assert_eq!(items[0].model_provider, "openai"); - assert_eq!(items[1].model_provider, "openai"); - assert!(items[0].path.is_absolute()); - assert!(next_cursor.is_some()); - - // Request the next page using the cursor - let req_id2 = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(2), - cursor: next_cursor, - model_providers: None, - }) - .await?; - let resp2: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(req_id2)), - ) - .await??; - let ListConversationsResponse { - items: items2, - next_cursor: next2, - .. - } = to_response::(resp2)?; - assert_eq!(items2.len(), 1); - assert_eq!(items2[0].preview, "Hello C"); - assert_eq!(items2[0].model_provider, "openai"); - assert_eq!(next2, None); - - // Add a conversation with an explicit non-OpenAI provider for filter tests. - create_fake_rollout( - codex_home.path(), - "2025-01-01T11-30-00", - "2025-01-01T11:30:00Z", - "Hello TP", - Some("test-provider"), - None, - )?; - - // Filtering by model provider should return only matching sessions. - let filter_req_id = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(10), - cursor: None, - model_providers: Some(vec!["test-provider".to_string()]), - }) - .await?; - let filter_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)), - ) - .await??; - let ListConversationsResponse { - items: filtered_items, - next_cursor: filtered_next, - } = to_response::(filter_resp)?; - assert_eq!(filtered_items.len(), 1); - assert_eq!(filtered_next, None); - assert_eq!(filtered_items[0].preview, "Hello TP"); - assert_eq!(filtered_items[0].model_provider, "test-provider"); - - // Empty filter should include every session regardless of provider metadata. - let unfiltered_req_id = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(10), - cursor: None, - model_providers: Some(Vec::new()), - }) - .await?; - let unfiltered_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(unfiltered_req_id)), - ) - .await??; - let ListConversationsResponse { - items: unfiltered_items, - next_cursor: unfiltered_next, - } = to_response::(unfiltered_resp)?; - assert_eq!(unfiltered_items.len(), 4); - assert!(unfiltered_next.is_none()); - - let empty_req_id = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(10), - cursor: None, - model_providers: Some(vec!["other".to_string()]), - }) - .await?; - let empty_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)), - ) - .await??; - let ListConversationsResponse { - items: empty_items, - next_cursor: empty_next, - } = to_response::(empty_resp)?; - assert!(empty_items.is_empty()); - assert!(empty_next.is_none()); - - let first_item = &items[0]; - - // Now resume one of the sessions from an explicit rollout path. - let resume_req_id = mcp - .send_resume_conversation_request(ResumeConversationParams { - path: Some(first_item.path.clone()), - conversation_id: None, - history: None, - overrides: Some(NewConversationParams { - model: Some("o3".to_string()), - ..Default::default() - }), - }) - .await?; - - // Expect a codex/event notification with msg.type == sessionConfigured - let notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("sessionConfigured"), - ) - .await??; - let session_configured: ServerNotification = notification.try_into()?; - let ServerNotification::SessionConfigured(SessionConfiguredNotification { - model, - rollout_path, - initial_messages: session_initial_messages, - .. - }) = session_configured - else { - unreachable!("expected sessionConfigured notification"); - }; - assert_eq!(model, "o3"); - assert_eq!(rollout_path, first_item.path.clone()); - let session_initial_messages = session_initial_messages - .expect("expected initial messages when resuming from rollout path"); - match session_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, first_item.preview.clone()); - } - other => panic!("unexpected initial messages from rollout resume: {other:#?}"), - } - - // Then the response for resumeConversation - let resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)), - ) - .await??; - let ResumeConversationResponse { - conversation_id, - model: resume_model, - initial_messages: response_initial_messages, - .. - } = to_response::(resume_resp)?; - // conversation id should be a valid UUID - assert!(!conversation_id.to_string().is_empty()); - assert_eq!(resume_model, "o3"); - let response_initial_messages = - response_initial_messages.expect("expected initial messages in resume response"); - match response_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, first_item.preview.clone()); - } - other => panic!("unexpected initial messages in resume response: {other:#?}"), - } - - // Resuming with only a conversation id should locate the rollout automatically. - let resume_by_id_req_id = mcp - .send_resume_conversation_request(ResumeConversationParams { - path: None, - conversation_id: Some(first_item.conversation_id), - history: None, - overrides: Some(NewConversationParams { - model: Some("o3".to_string()), - ..Default::default() - }), - }) - .await?; - let notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("sessionConfigured"), - ) - .await??; - let session_configured: ServerNotification = notification.try_into()?; - let ServerNotification::SessionConfigured(SessionConfiguredNotification { - model, - rollout_path, - initial_messages: session_initial_messages, - .. - }) = session_configured - else { - unreachable!("expected sessionConfigured notification"); - }; - assert_eq!(model, "o3"); - assert_eq!(rollout_path, first_item.path.clone()); - let session_initial_messages = session_initial_messages - .expect("expected initial messages when resuming from conversation id"); - match session_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, first_item.preview.clone()); - } - other => panic!("unexpected initial messages from conversation id resume: {other:#?}"), - } - let resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_by_id_req_id)), - ) - .await??; - let ResumeConversationResponse { - conversation_id: by_id_conversation_id, - model: by_id_model, - initial_messages: by_id_initial_messages, - .. - } = to_response::(resume_resp)?; - assert!(!by_id_conversation_id.to_string().is_empty()); - assert_eq!(by_id_model, "o3"); - let by_id_initial_messages = by_id_initial_messages - .expect("expected initial messages when resuming from conversation id response"); - match by_id_initial_messages.as_slice() { - [EventMsg::UserMessage(message)] => { - assert_eq!(message.message, first_item.preview.clone()); - } - other => { - panic!("unexpected initial messages in conversation id resume response: {other:#?}") - } - } - - // Resuming with explicit history should succeed even without a stored rollout. - let fork_history_text = "Hello from history"; - let history = vec![ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: fork_history_text.to_string(), - }], - end_turn: None, - phase: None, - }]; - let resume_with_history_req_id = mcp - .send_resume_conversation_request(ResumeConversationParams { - path: None, - conversation_id: None, - history: Some(history), - overrides: Some(NewConversationParams { - model: Some("o3".to_string()), - ..Default::default() - }), - }) - .await?; - let notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("sessionConfigured"), - ) - .await??; - let session_configured: ServerNotification = notification.try_into()?; - let ServerNotification::SessionConfigured(SessionConfiguredNotification { - model, - initial_messages: session_initial_messages, - .. - }) = session_configured - else { - unreachable!("expected sessionConfigured notification"); - }; - assert_eq!(model, "o3"); - assert!( - session_initial_messages.as_ref().is_none_or(Vec::is_empty), - "expected no initial messages when resuming from explicit history but got {session_initial_messages:#?}" - ); - let resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_with_history_req_id)), - ) - .await??; - let ResumeConversationResponse { - conversation_id: history_conversation_id, - model: history_model, - initial_messages: history_initial_messages, - .. - } = to_response::(resume_resp)?; - assert!(!history_conversation_id.to_string().is_empty()); - assert_eq!(history_model, "o3"); - assert!( - history_initial_messages.as_ref().is_none_or(Vec::is_empty), - "expected no initial messages in resume response when history is provided but got {history_initial_messages:#?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn list_conversations_fetches_through_filtered_pages() -> Result<()> { - let codex_home = TempDir::new()?; - - // Only the last 3 conversations match the provider filter; request 3 and - // ensure pagination keeps fetching past non-matching pages. - let cases = [ - ( - "2025-03-04T12-00-00", - "2025-03-04T12:00:00Z", - "skip_provider", - ), - ( - "2025-03-03T12-00-00", - "2025-03-03T12:00:00Z", - "skip_provider", - ), - ( - "2025-03-02T12-00-00", - "2025-03-02T12:00:00Z", - "target_provider", - ), - ( - "2025-03-01T12-00-00", - "2025-03-01T12:00:00Z", - "target_provider", - ), - ( - "2025-02-28T12-00-00", - "2025-02-28T12:00:00Z", - "target_provider", - ), - ]; - - for (ts_file, ts_rfc, provider) in cases { - create_fake_rollout( - codex_home.path(), - ts_file, - ts_rfc, - "Hello", - Some(provider), - None, - )?; - } - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let req_id = mcp - .send_list_conversations_request(ListConversationsParams { - page_size: Some(3), - cursor: None, - model_providers: Some(vec!["target_provider".to_string()]), - }) - .await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(req_id)), - ) - .await??; - let ListConversationsResponse { items, next_cursor } = - to_response::(resp)?; - - assert_eq!( - items.len(), - 3, - "should fetch across pages to satisfy the limit" - ); - assert!( - items - .iter() - .all(|item| item.model_provider == "target_provider") - ); - assert_eq!(next_cursor, None); - - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs deleted file mode 100644 index afc69787eb..0000000000 --- a/codex-rs/app-server/tests/suite/login.rs +++ /dev/null @@ -1,161 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::to_response; -use codex_app_server_protocol::GetAuthStatusParams; -use codex_app_server_protocol::GetAuthStatusResponse; -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::LoginChatGptResponse; -use codex_app_server_protocol::LogoutChatGptResponse; -use codex_app_server_protocol::RequestId; -use codex_core::auth::AuthCredentialsStoreMode; -use codex_login::login_with_api_key; -use serial_test::serial; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -// Helper to create a config.toml; mirrors create_conversation.rs -fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "http://127.0.0.1:0/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"#, - ) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn logout_chatgpt_removes_auth() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; - login_with_api_key( - codex_home.path(), - "sk-test-key", - AuthCredentialsStoreMode::File, - )?; - assert!(codex_home.path().join("auth.json").exists()); - - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let id = mcp.send_logout_chat_gpt_request().await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(id)), - ) - .await??; - let _ok: LogoutChatGptResponse = to_response(resp)?; - - assert!( - !codex_home.path().join("auth.json").exists(), - "auth.json should be deleted" - ); - - // Verify status reflects signed-out state. - let status_id = mcp - .send_get_auth_status_request(GetAuthStatusParams { - include_token: Some(true), - refresh_token: Some(false), - }) - .await?; - let status_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(status_id)), - ) - .await??; - let status: GetAuthStatusResponse = to_response(status_resp)?; - assert_eq!(status.auth_method, None); - assert_eq!(status.auth_token, None); - Ok(()) -} - -fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - let contents = format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" -forced_login_method = "{forced_method}" -"# - ); - std::fs::write(config_toml, contents) -} - -fn create_config_toml_forced_workspace( - codex_home: &Path, - workspace_id: &str, -) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - let contents = format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" -forced_chatgpt_workspace_id = "{workspace_id}" -"# - ); - std::fs::write(config_toml, contents) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn login_chatgpt_rejected_when_forced_api() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml_forced_login(codex_home.path(), "api")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_login_chat_gpt_request().await?; - let err: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), - ) - .await??; - - assert_eq!( - err.error.message, - "ChatGPT login is disabled. Use API key login instead." - ); - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -// Serialize tests that launch the login server since it binds to a fixed port. -#[serial(login_port)] -async fn login_chatgpt_includes_forced_workspace_query_param() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml_forced_workspace(codex_home.path(), "ws-forced")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_login_chat_gpt_request().await?; - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let login: LoginChatGptResponse = to_response(resp)?; - assert!( - login.auth_url.contains("allowed_workspace_id=ws-forced"), - "auth URL should include forced workspace" - ); - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index ae7e0cb438..c60f4a32a1 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -1,16 +1,4 @@ -mod archive_thread; mod auth; -mod codex_message_processor_flow; -mod config; -mod create_thread; -mod fork_thread; +mod conversation_summary; mod fuzzy_file_search; -mod interrupt; -mod list_resume; -mod login; -mod output_schema; -mod send_message; -mod set_default_model; -mod user_agent; -mod user_info; mod v2; diff --git a/codex-rs/app-server/tests/suite/output_schema.rs b/codex-rs/app-server/tests/suite/output_schema.rs deleted file mode 100644 index b182e4262e..0000000000 --- a/codex-rs/app-server/tests/suite/output_schema.rs +++ /dev/null @@ -1,372 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::to_response; -use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; -use codex_app_server::INVALID_PARAMS_ERROR_CODE; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::InputItem; -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SendUserTurnParams; -use codex_app_server_protocol::SendUserTurnResponse; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use core_test_support::responses; -use core_test_support::skip_if_no_network; -use pretty_assertions::assert_eq; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test] -async fn send_user_turn_accepts_output_schema_v1() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let body = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let response_mock = responses::mount_sse_once(&server, body).await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::(new_conv_resp)?; - - let listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(listener_id)), - ) - .await??; - - let output_schema = serde_json::json!({ - "type": "object", - "properties": { - "answer": { "type": "string" } - }, - "required": ["answer"], - "additionalProperties": false - }); - - let send_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: "Hello".to_string(), - text_elements: Vec::new(), - }], - cwd: codex_home.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: "mock-model".to_string(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: Some(output_schema.clone()), - }) - .await?; - let _send_turn_resp: SendUserTurnResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)), - ) - .await??, - )?; - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - let request = response_mock.single_request(); - let payload = request.body_json(); - let text = payload.get("text").expect("request missing text field"); - let format = text - .get("format") - .expect("request missing text.format field"); - assert_eq!( - format, - &serde_json::json!({ - "name": "codex_output_schema", - "type": "json_schema", - "strict": true, - "schema": output_schema, - }) - ); - - Ok(()) -} - -#[tokio::test] -async fn send_user_turn_rejects_oversized_input_v1() -> Result<()> { - let server = responses::start_mock_server().await; - let body = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let _response_mock = responses::mount_sse_once(&server, body).await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::(new_conv_resp)?; - - let listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(listener_id)), - ) - .await??; - - let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - let send_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: oversized_input.clone(), - text_elements: Vec::new(), - }], - cwd: codex_home.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - model: "mock-model".to_string(), - effort: Some(ReasoningEffort::Low), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: None, - }) - .await?; - - let err: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(send_turn_id)), - ) - .await??; - - assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); - assert_eq!( - err.error.message, - format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") - ); - let data = err.error.data.expect("expected structured error data"); - assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); - assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); - assert_eq!(data["actual_chars"], oversized_input.chars().count()); - - Ok(()) -} - -#[tokio::test] -async fn send_user_turn_output_schema_is_per_turn_v1() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let body1 = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let response_mock1 = responses::mount_sse_once(&server, body1).await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::(new_conv_resp)?; - - let listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(listener_id)), - ) - .await??; - - let output_schema = serde_json::json!({ - "type": "object", - "properties": { - "answer": { "type": "string" } - }, - "required": ["answer"], - "additionalProperties": false - }); - - let send_turn_id = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: "Hello".to_string(), - text_elements: Vec::new(), - }], - cwd: codex_home.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: "mock-model".to_string(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: Some(output_schema.clone()), - }) - .await?; - let _send_turn_resp: SendUserTurnResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)), - ) - .await??, - )?; - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - let payload1 = response_mock1.single_request().body_json(); - assert_eq!( - payload1.pointer("/text/format"), - Some(&serde_json::json!({ - "name": "codex_output_schema", - "type": "json_schema", - "strict": true, - "schema": output_schema, - })) - ); - - let body2 = responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-2", "Done"), - responses::ev_completed("resp-2"), - ]); - let response_mock2 = responses::mount_sse_once(&server, body2).await; - - let send_turn_id_2 = mcp - .send_send_user_turn_request(SendUserTurnParams { - conversation_id, - items: vec![InputItem::Text { - text: "Hello again".to_string(), - text_elements: Vec::new(), - }], - cwd: codex_home.path().to_path_buf(), - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - model: "mock-model".to_string(), - effort: Some(ReasoningEffort::Medium), - summary: ReasoningSummary::Auto, - service_tier: None, - output_schema: None, - }) - .await?; - let _send_turn_resp_2: SendUserTurnResponse = to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id_2)), - ) - .await??, - )?; - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - - let payload2 = response_mock2.single_request().body_json(); - assert_eq!(payload2.pointer("/text/format"), None); - - Ok(()) -} - -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs deleted file mode 100644 index 725f327b4c..0000000000 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ /dev/null @@ -1,645 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_fake_rollout; -use app_test_support::rollout_path; -use app_test_support::to_response; -use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; -use codex_app_server::INVALID_PARAMS_ERROR_CODE; -use codex_app_server_protocol::AddConversationListenerParams; -use codex_app_server_protocol::AddConversationSubscriptionResponse; -use codex_app_server_protocol::InputItem; -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::NewConversationResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ResumeConversationParams; -use codex_app_server_protocol::ResumeConversationResponse; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserMessageResponse; -use codex_execpolicy::Policy; -use codex_protocol::ThreadId; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::models::ContentItem; -use codex_protocol::models::DeveloperInstructions; -use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::RawResponseItemEvent; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::TurnContextItem; -use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use core_test_support::responses; -use pretty_assertions::assert_eq; -use std::io::Write; -use std::path::Path; -use std::path::PathBuf; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test] -async fn test_send_message_success() -> Result<()> { - // Spin up a mock responses server that immediately ends the Codex turn. - // Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses. - let server = responses::start_mock_server().await; - let body1 = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let body2 = responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-2", "Done"), - responses::ev_completed("resp-2"), - ]); - let _response_mock1 = responses::mount_sse_once(&server, body1).await; - let _response_mock2 = responses::mount_sse_once(&server, body2).await; - - // Create a temporary Codex home with config pointing at the mock server. - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - // Start MCP server process and initialize. - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - // Start a conversation using the new wire API. - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::<_>(new_conv_resp)?; - - // 2) addConversationListener - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let add_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - let AddConversationSubscriptionResponse { subscription_id: _ } = - to_response::<_>(add_listener_resp)?; - - // Now exercise sendUserMessage twice. - send_message("Hello", conversation_id, &mut mcp).await?; - send_message("Hello again", conversation_id, &mut mcp).await?; - Ok(()) -} - -#[expect(clippy::expect_used)] -async fn send_message( - message: &str, - conversation_id: ThreadId, - mcp: &mut McpProcess, -) -> Result<()> { - // Now exercise sendUserMessage. - let send_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![InputItem::Text { - text: message.to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_id)), - ) - .await??; - - let _ok: SendUserMessageResponse = to_response::(response)?; - - // Verify the task_finished notification is received. - // Note this also ensures that the final request to the server was made. - let task_finished_notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await??; - let serde_json::Value::Object(map) = task_finished_notification - .params - .expect("notification should have params") - else { - panic!("task_finished_notification should have params"); - }; - assert_eq!( - map.get("conversationId") - .expect("should have conversationId"), - &serde_json::Value::String(conversation_id.to_string()) - ); - - let raw_attempt = tokio::time::timeout( - std::time::Duration::from_millis(200), - mcp.read_stream_until_notification_message("codex/event/raw_response_item"), - ) - .await; - assert!( - raw_attempt.is_err(), - "unexpected raw item notification when not opted in" - ); - Ok(()) -} - -#[tokio::test] -async fn test_send_message_raw_notifications_opt_in() -> Result<()> { - let server = responses::start_mock_server().await; - let body = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let _response_mock = responses::mount_sse_once(&server, body).await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - developer_instructions: Some("Use the test harness tools.".to_string()), - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::<_>(new_conv_resp)?; - - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: true, - }) - .await?; - let add_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - let AddConversationSubscriptionResponse { subscription_id: _ } = - to_response::<_>(add_listener_resp)?; - - let send_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![InputItem::Text { - text: "Hello".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - - let developer = read_raw_response_item(&mut mcp, conversation_id).await; - assert_permissions_message(&developer); - assert_developer_message(&developer, "Use the test harness tools."); - - let contextual_user = read_raw_response_item(&mut mcp, conversation_id).await; - assert_instructions_message(&contextual_user); - assert_environment_message(&contextual_user); - - let response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(send_id)), - ) - .await??; - let _ok: SendUserMessageResponse = to_response::(response)?; - - let user_message = read_raw_response_item(&mut mcp, conversation_id).await; - assert_user_message(&user_message, "Hello"); - - let assistant_message = read_raw_response_item(&mut mcp, conversation_id).await; - assert_assistant_message(&assistant_message, "Done"); - - let _ = tokio::time::timeout( - std::time::Duration::from_millis(250), - mcp.read_stream_until_notification_message("codex/event/task_complete"), - ) - .await; - - Ok(()) -} - -#[tokio::test] -async fn test_send_message_session_not_found() -> Result<()> { - // Start MCP without creating a Codex session - let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let unknown = ThreadId::new(); - let req_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id: unknown, - items: vec![InputItem::Text { - text: "ping".to_string(), - text_elements: Vec::new(), - }], - }) - .await?; - - // Expect an error response for unknown conversation. - let err = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(req_id)), - ) - .await??; - assert_eq!(err.id, RequestId::Integer(req_id)); - Ok(()) -} - -#[tokio::test] -async fn test_send_message_rejects_oversized_input() -> Result<()> { - let server = responses::start_mock_server().await; - let body = responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]); - let _response_mock = responses::mount_sse_once(&server, body).await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams { - ..Default::default() - }) - .await?; - let new_conv_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), - ) - .await??; - let NewConversationResponse { - conversation_id, .. - } = to_response::<_>(new_conv_resp)?; - - let oversized_input = "x".repeat(MAX_USER_INPUT_TEXT_CHARS + 1); - let req_id = mcp - .send_send_user_message_request(SendUserMessageParams { - conversation_id, - items: vec![InputItem::Text { - text: oversized_input.clone(), - text_elements: Vec::new(), - }], - }) - .await?; - - let err: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(req_id)), - ) - .await??; - - assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); - assert_eq!( - err.error.message, - format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") - ); - let data = err.error.data.expect("expected structured error data"); - assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); - assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); - assert_eq!(data["actual_chars"], oversized_input.chars().count()); - - Ok(()) -} - -#[tokio::test] -async fn resume_with_model_mismatch_appends_model_switch_once() -> Result<()> { - let server = responses::start_mock_server().await; - let response_mock = responses::mount_sse_sequence( - &server, - vec![ - responses::sse(vec![ - responses::ev_response_created("resp-1"), - responses::ev_assistant_message("msg-1", "Done"), - responses::ev_completed("resp-1"), - ]), - responses::sse(vec![ - responses::ev_response_created("resp-2"), - responses::ev_assistant_message("msg-2", "Done again"), - responses::ev_completed("resp-2"), - ]), - ], - ) - .await; - - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; - - let filename_ts = "2025-01-02T12-00-00"; - let meta_rfc3339 = "2025-01-02T12:00:00Z"; - let preview = "Resume me"; - let conversation_id = create_fake_rollout( - codex_home.path(), - filename_ts, - meta_rfc3339, - preview, - Some("mock_provider"), - None, - )?; - let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); - append_rollout_turn_context(&rollout_path, meta_rfc3339, "previous-model")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let resume_id = mcp - .send_resume_conversation_request(ResumeConversationParams { - path: Some(rollout_path.clone()), - conversation_id: None, - history: None, - overrides: Some(NewConversationParams { - model: Some("gpt-5.2-codex".to_string()), - ..Default::default() - }), - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("sessionConfigured"), - ) - .await??; - let resume_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), - ) - .await??; - let ResumeConversationResponse { - conversation_id, .. - } = to_response::(resume_resp)?; - - let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { - conversation_id, - experimental_raw_events: false, - }) - .await?; - let add_listener_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await??; - let AddConversationSubscriptionResponse { subscription_id: _ } = - to_response::<_>(add_listener_resp)?; - - send_message("hello after resume", conversation_id, &mut mcp).await?; - send_message("second turn", conversation_id, &mut mcp).await?; - - let requests = response_mock.requests(); - assert_eq!(requests.len(), 2, "expected two model requests"); - - let first_developer_texts = requests[0].message_input_texts("developer"); - let first_model_switch_count = first_developer_texts - .iter() - .filter(|text| text.contains("")) - .count(); - assert!( - first_model_switch_count >= 1, - "expected model switch message on first post-resume turn, got {first_developer_texts:?}" - ); - - let second_developer_texts = requests[1].message_input_texts("developer"); - let second_model_switch_count = second_developer_texts - .iter() - .filter(|text| text.contains("")) - .count(); - assert_eq!( - second_model_switch_count, 1, - "did not expect duplicate model switch message on second post-resume turn, got {second_developer_texts:?}" - ); - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "danger-full-access" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} - -#[expect(clippy::expect_used)] -async fn read_raw_response_item(mcp: &mut McpProcess, conversation_id: ThreadId) -> ResponseItem { - // TODO: Switch to rawResponseItem/completed once we migrate to app server v2 in codex web. - loop { - let raw_notification: JSONRPCNotification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/raw_response_item"), - ) - .await - .expect("codex/event/raw_response_item notification timeout") - .expect("codex/event/raw_response_item notification resp"); - - let serde_json::Value::Object(params) = raw_notification - .params - .expect("codex/event/raw_response_item should have params") - else { - panic!("codex/event/raw_response_item should have params"); - }; - - let conversation_id_value = params - .get("conversationId") - .and_then(|value| value.as_str()) - .expect("raw response item should include conversationId"); - - assert_eq!( - conversation_id_value, - conversation_id.to_string(), - "raw response item conversation mismatch" - ); - - let msg_value = params - .get("msg") - .cloned() - .expect("raw response item should include msg payload"); - - // Ghost snapshots are produced concurrently and may arrive before the model reply. - let event: RawResponseItemEvent = - serde_json::from_value(msg_value).expect("deserialize raw response item"); - if !matches!(event.item, ResponseItem::GhostSnapshot { .. }) { - return event.item; - } - } -} - -fn assert_instructions_message(item: &ResponseItem) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "user"); - let texts = content_texts(content); - let is_instructions = texts - .iter() - .any(|text| text.starts_with("# AGENTS.md instructions for ")); - assert!( - is_instructions, - "expected instructions message, got {texts:?}" - ); - } - other => panic!("expected instructions message, got {other:?}"), - } -} - -fn assert_permissions_message(item: &ResponseItem) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "developer"); - let texts = content_texts(content); - let expected = DeveloperInstructions::from_policy( - &SandboxPolicy::DangerFullAccess, - AskForApproval::Never, - &Policy::empty(), - &PathBuf::from("/tmp"), - false, - ) - .into_text(); - assert!( - texts.iter().any(|text| *text == expected), - "expected permissions developer message, got {texts:?}" - ); - } - other => panic!("expected permissions message, got {other:?}"), - } -} - -fn assert_developer_message(item: &ResponseItem, expected_text: &str) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "developer"); - let texts = content_texts(content); - assert!( - texts.contains(&expected_text), - "expected developer instructions message, got {texts:?}" - ); - } - other => panic!("expected developer instructions message, got {other:?}"), - } -} - -fn assert_environment_message(item: &ResponseItem) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "user"); - let texts = content_texts(content); - assert!( - texts - .iter() - .any(|text| text.contains("")), - "expected environment context message, got {texts:?}" - ); - } - other => panic!("expected environment message, got {other:?}"), - } -} - -fn assert_user_message(item: &ResponseItem, expected_text: &str) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "user"); - let texts = content_texts(content); - assert_eq!(texts, vec![expected_text]); - } - other => panic!("expected user message, got {other:?}"), - } -} - -fn assert_assistant_message(item: &ResponseItem, expected_text: &str) { - match item { - ResponseItem::Message { role, content, .. } => { - assert_eq!(role, "assistant"); - let texts = content_texts(content); - assert_eq!(texts, vec![expected_text]); - } - other => panic!("expected assistant message, got {other:?}"), - } -} - -fn content_texts(content: &[ContentItem]) -> Vec<&str> { - content - .iter() - .filter_map(|item| match item { - ContentItem::InputText { text, .. } | ContentItem::OutputText { text } => { - Some(text.as_str()) - } - _ => None, - }) - .collect() -} - -fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std::io::Result<()> { - let line = RolloutLine { - timestamp: timestamp.to_string(), - item: RolloutItem::TurnContext(TurnContextItem { - turn_id: None, - cwd: PathBuf::from("/"), - current_date: None, - timezone: None, - approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, - network: None, - model: model.to_string(), - personality: None, - collaboration_mode: None, - realtime_active: Some(false), - effort: None, - summary: ReasoningSummary::Auto, - user_instructions: None, - developer_instructions: None, - final_output_json_schema: None, - truncation_policy: None, - }), - }; - let serialized = serde_json::to_string(&line).map_err(std::io::Error::other)?; - std::fs::OpenOptions::new() - .append(true) - .open(path)? - .write_all(format!("{serialized}\n").as_bytes()) -} diff --git a/codex-rs/app-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs deleted file mode 100644 index b56c54dbd9..0000000000 --- a/codex-rs/app-server/tests/suite/set_default_model.rs +++ /dev/null @@ -1,64 +0,0 @@ -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::to_response; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::SetDefaultModelParams; -use codex_app_server_protocol::SetDefaultModelResponse; -use codex_core::config::ConfigToml; -use pretty_assertions::assert_eq; -use std::path::Path; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn set_default_model_persists_overrides() -> Result<()> { - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let params = SetDefaultModelParams { - model: Some("gpt-4.1".to_string()), - reasoning_effort: None, - }; - - let request_id = mcp.send_set_default_model_request(params).await?; - - let resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let _: SetDefaultModelResponse = to_response(resp)?; - - let config_path = codex_home.path().join("config.toml"); - let config_contents = tokio::fs::read_to_string(&config_path).await?; - let config_toml: ConfigToml = toml::from_str(&config_contents)?; - - assert_eq!( - ConfigToml { - model: Some("gpt-4.1".to_string()), - model_reasoning_effort: None, - ..Default::default() - }, - config_toml, - ); - Ok(()) -} - -// Helper to create a config.toml; mirrors create_conversation.rs -fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - r#" -model = "gpt-5.1-codex-max" -model_reasoning_effort = "medium" -"#, - ) -} diff --git a/codex-rs/app-server/tests/suite/user_agent.rs b/codex-rs/app-server/tests/suite/user_agent.rs deleted file mode 100644 index 9178a3ef58..0000000000 --- a/codex-rs/app-server/tests/suite/user_agent.rs +++ /dev/null @@ -1,43 +0,0 @@ -use anyhow::Result; -use app_test_support::DEFAULT_CLIENT_NAME; -use app_test_support::McpProcess; -use app_test_support::to_response; -use codex_app_server_protocol::GetUserAgentResponse; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use pretty_assertions::assert_eq; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> { - let codex_home = TempDir::new()?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_get_user_agent_request().await?; - let response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let os_info = os_info::get(); - let originator = DEFAULT_CLIENT_NAME; - let os_type = os_info.os_type(); - let os_version = os_info.version(); - let architecture = os_info.architecture().unwrap_or("unknown"); - let terminal_ua = codex_core::terminal::user_agent(); - let user_agent = format!( - "{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} ({DEFAULT_CLIENT_NAME}; 0.1.0)" - ); - - let received: GetUserAgentResponse = to_response(response)?; - let expected = GetUserAgentResponse { user_agent }; - - assert_eq!(received, expected); - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs deleted file mode 100644 index 6a44f1a3ab..0000000000 --- a/codex-rs/app-server/tests/suite/user_info.rs +++ /dev/null @@ -1,46 +0,0 @@ -use anyhow::Result; -use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; -use app_test_support::to_response; -use app_test_support::write_chatgpt_auth; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::UserInfoResponse; -use codex_core::auth::AuthCredentialsStoreMode; -use pretty_assertions::assert_eq; -use std::time::Duration; -use tempfile::TempDir; -use tokio::time::timeout; - -const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_info_returns_email_from_auth_json() -> Result<()> { - let codex_home = TempDir::new()?; - - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("access") - .refresh_token("refresh") - .email("user@example.com"), - AuthCredentialsStoreMode::File, - )?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp.send_user_info_request().await?; - let response: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let received: UserInfoResponse = to_response(response)?; - let expected = UserInfoResponse { - alleged_user_email: Some("user@example.com".to_string()), - }; - - assert_eq!(received, expected); - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 6f66d7bff7..51048202f6 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -6,7 +6,7 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::GetAccountRateLimitsResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; @@ -221,17 +221,14 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { } async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { - let request_id = mcp - .send_login_api_key_request(LoginApiKeyParams { - api_key: api_key.to_string(), - }) - .await?; - - timeout( + let request_id = mcp.send_login_account_api_key_request(api_key).await?; + let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let login: LoginAccountResponse = to_response(response)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 139e8b378a..71150d7126 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -5,7 +5,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; @@ -350,17 +350,14 @@ async fn read_notification(mcp: &mut McpProcess, method: &s } async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { - let request_id = mcp - .send_login_api_key_request(LoginApiKeyParams { - api_key: api_key.to_string(), - }) - .await?; - - timeout( + let request_id = mcp.send_login_account_api_key_request(api_key).await?; + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let login: LoginAccountResponse = to_response(response)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); Ok(()) } diff --git a/codex-rs/artifact-presentation/Cargo.toml b/codex-rs/artifact-presentation/Cargo.toml index b30809f9e9..7c7bf6b10f 100644 --- a/codex-rs/artifact-presentation/Cargo.toml +++ b/codex-rs/artifact-presentation/Cargo.toml @@ -14,9 +14,9 @@ workspace = true [dependencies] base64 = { workspace = true } font8x8 = { workspace = true } +fontdue = { workspace = true } image = { workspace = true, features = ["jpeg", "png"] } ppt-rs = { workspace = true } -reqwest = { workspace = true, features = ["blocking"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny-skia = { workspace = true } @@ -27,4 +27,3 @@ zip = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } tempfile = { workspace = true } -tiny_http = { workspace = true } diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs index 65fd637687..24ef78bcc7 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/api.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/api.rs @@ -4,9 +4,6 @@ use image::GenericImageView; use image::ImageFormat; use image::codecs::jpeg::JpegEncoder; use image::imageops::FilterType; -use ppt_rs::Chart; -use ppt_rs::ChartSeries; -use ppt_rs::ChartType; use ppt_rs::Hyperlink as PptHyperlink; use ppt_rs::HyperlinkAction as PptHyperlinkAction; use ppt_rs::Image; @@ -164,8 +161,16 @@ impl PresentationArtifactRequest { }], ImageInputSource::DataUrl(_) | ImageInputSource::Blob(_) - | ImageInputSource::Uri(_) | ImageInputSource::Placeholder => Vec::new(), + ImageInputSource::Uri(uri) => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: self.action.clone(), + message: format!( + "remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)", + self.action + ), + }); + } } } "replace_image" => { @@ -184,8 +189,16 @@ impl PresentationArtifactRequest { }], (None, Some(_), None, None, None) | (None, None, Some(_), None, None) - | (None, None, None, Some(_), None) | (None, None, None, None, Some(_)) => Vec::new(), + (None, None, None, Some(uri), None) => { + return Err(PresentationArtifactError::UnsupportedFeature { + action: self.action.clone(), + message: format!( + "remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)", + self.action + ), + }); + } _ => { return Err(PresentationArtifactError::InvalidArgs { action: self.action.clone(), diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs index 86ba674f4a..629f0e332e 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/manager.rs @@ -104,6 +104,7 @@ impl PresentationArtifactManager { "get_summary" => self.get_summary(request), "list_slides" => self.list_slides(request), "list_layouts" => self.list_layouts(request), + "list_masters" => self.list_masters(request), "list_layout_placeholders" => self.list_layout_placeholders(request), "list_slide_placeholders" => self.list_slide_placeholders(request), "inspect" => self.inspect(request), @@ -149,6 +150,8 @@ impl PresentationArtifactManager { "insert_text_after" => self.insert_text_after(request), "set_hyperlink" => self.set_hyperlink(request), "set_comment_author" => self.set_comment_author(request), + "list_comment_threads" => self.list_comment_threads(request), + "get_comment_thread" => self.get_comment_thread(request), "add_comment_thread" => self.add_comment_thread(request), "add_comment_reply" => self.add_comment_reply(request), "toggle_comment_reaction" => self.toggle_comment_reaction(request), @@ -216,6 +219,9 @@ impl PresentationArtifactManager { } })?; let mut document = PresentationDocument::from_ppt_rs(imported); + if let Some(slide_size) = import_pptx_slide_size(&path)? { + document.slide_size = slide_size; + } import_pptx_images(&path, &mut document, &request.action)?; document }; @@ -307,7 +313,7 @@ impl PresentationArtifactManager { document, slide_index, RenderOptions { - scale: 1.0, + scale, include_background: true, }, )?; @@ -315,7 +321,7 @@ impl PresentationArtifactManager { &png_bytes, &output_path, preview_format, - scale, + 1.0, quality, &request.action, )?; @@ -332,7 +338,7 @@ impl PresentationArtifactManager { document, slide_index, RenderOptions { - scale: 1.0, + scale, include_background: true, }, )?; @@ -342,7 +348,7 @@ impl PresentationArtifactManager { &png_bytes, &target, preview_format, - scale, + 1.0, quality, &request.action, )?; @@ -453,6 +459,24 @@ impl PresentationArtifactManager { Ok(response) } + fn list_masters( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let masters = master_layout_list(document); + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} masters", masters.len()), + snapshot_for_document(document), + ); + response.layout_list = Some(masters); + response.theme = Some(document.theme_snapshot()); + Ok(response) + } + fn list_layout_placeholders( &mut self, request: PresentationArtifactRequest, @@ -1568,10 +1592,6 @@ impl PresentationArtifactManager { ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?), ImageInputSource::Placeholder => None, }; - let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch); - let lock_aspect_ratio = args - .lock_aspect_ratio - .unwrap_or(fit_mode != ImageFitMode::Stretch); let crop = args .crop .map(|crop| normalize_image_crop(crop, &request.action)) @@ -1585,8 +1605,10 @@ impl PresentationArtifactManager { }); }; image.payload = image_payload; - image.fit_mode = fit_mode; - image.crop = crop; + image.fit_mode = args.fit.unwrap_or(image.fit_mode); + if let Some(crop) = crop { + image.crop = Some(crop); + } if let Some(rotation) = args.rotation { image.rotation_degrees = Some(rotation); } @@ -1596,9 +1618,15 @@ impl PresentationArtifactManager { if let Some(flip_vertical) = args.flip_vertical { image.flip_vertical = flip_vertical; } - image.lock_aspect_ratio = lock_aspect_ratio; - image.alt_text = args.alt; - image.prompt = args.prompt; + if let Some(lock_aspect_ratio) = args.lock_aspect_ratio { + image.lock_aspect_ratio = lock_aspect_ratio; + } + if let Some(alt) = args.alt { + image.alt_text = Some(alt); + } + if let Some(prompt) = args.prompt { + image.prompt = Some(prompt); + } image.is_placeholder = is_placeholder; Ok(PresentationArtifactResponse::new( artifact_id, @@ -1850,12 +1878,43 @@ impl PresentationArtifactManager { message: format!("element `{}` is not a table", args.element_id), }); }; + let row_count = table.rows.len(); + let column_count = table.rows.first().map(Vec::len).unwrap_or(0); + let start_row = args.start_row as usize; + let end_row = args.end_row as usize; + let start_column = args.start_column as usize; + let end_column = args.end_column as usize; + if start_row > end_row || start_column > end_column { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: "merge bounds must be ordered from top-left to bottom-right".to_string(), + }); + } + if end_row >= row_count || end_column >= column_count { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!( + "merge bounds [{start_row},{start_column}]..=[{end_row},{end_column}] exceed table size {row_count}x{column_count}" + ), + }); + } let region = TableMergeRegion { - start_row: args.start_row as usize, - end_row: args.end_row as usize, - start_column: args.start_column as usize, - end_column: args.end_column as usize, + start_row, + end_row, + start_column, + end_column, }; + if table.merges.iter().any(|merge| { + merge.start_row <= region.end_row + && region.start_row <= merge.end_row + && merge.start_column <= region.end_column + && region.start_column <= merge.end_column + }) { + return Err(PresentationArtifactError::InvalidArgs { + action: request.action, + message: format!("merge region overlaps an existing merge in `{}`", args.element_id), + }); + } table.merges.push(region); Ok(PresentationArtifactResponse::new( artifact_id, @@ -2512,6 +2571,54 @@ impl PresentationArtifactManager { )) } + fn list_comment_threads( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Listed {} comment threads", document.comment_threads.len()), + snapshot_for_document(document), + ); + response.resolved_record = Some(serde_json::json!({ + "commentSelf": document.comment_self.as_ref().map(comment_author_to_proto), + "commentThreads": document + .comment_threads + .iter() + .map(comment_thread_to_proto) + .collect::>(), + })); + Ok(response) + } + + fn get_comment_thread( + &mut self, + request: PresentationArtifactRequest, + ) -> Result { + let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?; + let artifact_id = required_artifact_id(&request)?; + let document = self.get_document(&artifact_id, &request.action)?; + let thread = document + .comment_threads + .iter() + .find(|thread| thread.thread_id == args.thread_id) + .ok_or_else(|| PresentationArtifactError::InvalidArgs { + action: request.action.clone(), + message: format!("unknown comment thread `{}`", args.thread_id), + })?; + let mut response = PresentationArtifactResponse::new( + artifact_id, + request.action, + format!("Retrieved comment thread `{}`", args.thread_id), + snapshot_for_document(document), + ); + response.resolved_record = Some(comment_thread_to_proto(thread)); + Ok(response) + } + fn add_comment_thread( &mut self, request: PresentationArtifactRequest, @@ -2742,9 +2849,40 @@ impl PresentationArtifactManager { || text_layout.wrap.is_some() || text_layout.auto_fit.is_some() || text_layout.vertical_alignment.is_some(); + let position_rotation = args + .position + .as_ref() + .and_then(|position| position.rotation); + let position_flip_horizontal = args + .position + .as_ref() + .and_then(|position| position.flip_horizontal); + let position_flip_vertical = args + .position + .as_ref() + .and_then(|position| position.flip_vertical); + let has_position_transform = position_rotation.is_some() + || position_flip_horizontal.is_some() + || position_flip_vertical.is_some(); + let has_image_fields = + args.fit.is_some() || args.crop.is_some() || args.lock_aspect_ratio.is_some(); let element = document.find_element_mut(&args.element_id, &request.action)?; match element { PresentationElement::Text(text) => { + if args.stroke.is_some() + || args.rotation.is_some() + || args.flip_horizontal.is_some() + || args.flip_vertical.is_some() + || has_position_transform + || has_image_fields + { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: + "text elements support only `position`, `z_order`, `fill`, and `text_layout` updates" + .to_string(), + }); + } if let Some(position) = args.position { text.frame = apply_partial_position(text.frame, position); } @@ -2754,32 +2892,23 @@ impl PresentationArtifactManager { if has_text_layout { text.rich_text.layout = text_layout; } - if args.stroke.is_some() - || args.rotation.is_some() - || args.flip_horizontal.is_some() - || args.flip_vertical.is_some() - { + } + PresentationElement::Shape(shape) => { + if has_image_fields { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, message: - "text elements support only `position`, `z_order`, and `fill` updates" + "shape elements support only `position`, `fill`, `stroke`, `rotation`, `flip_horizontal`, `flip_vertical`, `z_order`, and `text_layout` updates" .to_string(), }); } - } - PresentationElement::Shape(shape) => { - let position_rotation = args - .position - .as_ref() - .and_then(|position| position.rotation); - let position_flip_horizontal = args - .position - .as_ref() - .and_then(|position| position.flip_horizontal); - let position_flip_vertical = args - .position - .as_ref() - .and_then(|position| position.flip_vertical); + if has_text_layout && shape.text.is_none() { + return Err(PresentationArtifactError::UnsupportedFeature { + action: request.action, + message: "shape elements without text do not support `text_layout` updates" + .to_string(), + }); + } if let Some(position) = args.position { shape.frame = apply_partial_position(shape.frame, position); } @@ -2810,9 +2939,9 @@ impl PresentationArtifactManager { || args.rotation.is_some() || args.flip_horizontal.is_some() || args.flip_vertical.is_some() - || args.fit.is_some() - || args.crop.is_some() - || args.lock_aspect_ratio.is_some() + || has_position_transform + || has_image_fields + || has_text_layout { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, @@ -2845,19 +2974,7 @@ impl PresentationArtifactManager { } } PresentationElement::Image(image) => { - let position_rotation = args - .position - .as_ref() - .and_then(|position| position.rotation); - let position_flip_horizontal = args - .position - .as_ref() - .and_then(|position| position.flip_horizontal); - let position_flip_vertical = args - .position - .as_ref() - .and_then(|position| position.flip_vertical); - if args.fill.is_some() || args.stroke.is_some() { + if args.fill.is_some() || args.stroke.is_some() || has_text_layout { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, message: @@ -2896,6 +3013,9 @@ impl PresentationArtifactManager { || args.rotation.is_some() || args.flip_horizontal.is_some() || args.flip_vertical.is_some() + || has_position_transform + || has_image_fields + || has_text_layout { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, @@ -2913,6 +3033,9 @@ impl PresentationArtifactManager { || args.rotation.is_some() || args.flip_horizontal.is_some() || args.flip_vertical.is_some() + || has_position_transform + || has_image_fields + || has_text_layout { return Err(PresentationArtifactError::UnsupportedFeature { action: request.action, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs index b5cd5d00a3..c2fc143fec 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/model.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/model.rs @@ -85,14 +85,14 @@ struct NamedTextStyle { built_in: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct HyperlinkState { target: HyperlinkTarget, tooltip: Option, highlight_click: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] enum HyperlinkTarget { Url(String), Slide(u32), @@ -132,6 +132,44 @@ impl HyperlinkTarget { fn is_external(&self) -> bool { matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_)) } + + fn adjust_for_insert(&mut self, inserted_index: usize) { + if let Self::Slide(slide_index) = self + && *slide_index as usize >= inserted_index + { + *slide_index += 1; + } + } + + fn adjust_for_move(&mut self, from_index: usize, to_index: usize) { + let Self::Slide(slide_index_ref) = self else { + return; + }; + let slide_index = *slide_index_ref as usize; + *slide_index_ref = if slide_index == from_index { + to_index as u32 + } else if from_index < to_index && (from_index + 1..=to_index).contains(&slide_index) { + (slide_index - 1) as u32 + } else if to_index < from_index && (to_index..from_index).contains(&slide_index) { + (slide_index + 1) as u32 + } else { + *slide_index_ref + }; + } + + fn adjust_for_delete(&mut self, deleted_index: usize) -> bool { + let Self::Slide(slide_index_ref) = self else { + return false; + }; + let slide_index = *slide_index_ref as usize; + if slide_index == deleted_index { + return true; + } + if slide_index > deleted_index { + *slide_index_ref -= 1; + } + false + } } impl HyperlinkState { @@ -217,6 +255,18 @@ impl HyperlinkState { ppt_rs::escape_xml(&self.target.relationship_target()), ) } + + fn adjust_for_insert(&mut self, inserted_index: usize) { + self.target.adjust_for_insert(inserted_index); + } + + fn adjust_for_move(&mut self, from_index: usize, to_index: usize) { + self.target.adjust_for_move(from_index, to_index); + } + + fn adjust_for_delete(&mut self, deleted_index: usize) -> bool { + self.target.adjust_for_delete(deleted_index) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -285,7 +335,7 @@ enum TextVerticalAlignment { } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CommentAuthorProfile { +pub(super) struct CommentAuthorProfile { display_name: String, initials: String, email: Option, @@ -332,7 +382,7 @@ enum CommentTarget { } #[derive(Debug, Clone, Serialize, Deserialize)] -struct CommentThread { +pub(super) struct CommentThread { thread_id: String, target: CommentTarget, position: Option, @@ -714,6 +764,7 @@ impl PresentationDocument { } Some(_) => {} } + self.adjust_hyperlinks_for_insert(inserted_index); } fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) { @@ -728,6 +779,7 @@ impl PresentationDocument { active_index }); } + self.adjust_hyperlinks_for_move(from_index, to_index); } fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) { @@ -740,6 +792,36 @@ impl PresentationDocument { Some(active_index) if deleted_index < active_index => Some(active_index - 1), Some(active_index) => Some(active_index), }; + self.adjust_hyperlinks_for_delete(deleted_index); + } + + fn adjust_hyperlinks_for_insert(&mut self, inserted_index: usize) { + self.visit_hyperlinks_mut(|hyperlink| { + hyperlink.adjust_for_insert(inserted_index); + true + }); + } + + fn adjust_hyperlinks_for_move(&mut self, from_index: usize, to_index: usize) { + self.visit_hyperlinks_mut(|hyperlink| { + hyperlink.adjust_for_move(from_index, to_index); + true + }); + } + + fn adjust_hyperlinks_for_delete(&mut self, deleted_index: usize) { + self.visit_hyperlinks_mut(|hyperlink| !hyperlink.adjust_for_delete(deleted_index)); + } + + fn visit_hyperlinks_mut(&mut self, mut visit: F) + where + F: FnMut(&mut HyperlinkState) -> bool, + { + for slide in &mut self.slides { + for element in &mut slide.elements { + element.visit_hyperlinks_mut(&mut visit); + } + } } fn next_layout_id(&mut self) -> String { @@ -926,16 +1008,16 @@ impl PresentationDocument { }) } - fn to_ppt_rs(&self) -> Presentation { + fn to_ppt_rs(&self) -> Result { let mut presentation = self .name .as_deref() .map(Presentation::with_title) .unwrap_or_default(); for slide in &self.slides { - presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size)); + presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size, self)?); } - presentation + Ok(presentation) } } @@ -1053,6 +1135,38 @@ fn import_pptx_images( Ok(()) } +fn import_pptx_slide_size(path: &Path) -> Result, PresentationArtifactError> { + let file = std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let mut archive = + ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + let Some(xml) = zip_entry_string_if_exists(&mut archive, "ppt/presentation.xml").map_err( + |message| PresentationArtifactError::ImportFailed { + path: path.to_path_buf(), + message, + }, + )? + else { + return Ok(None); + }; + let Some(width) = + xml_tag_attribute(&xml, "().ok()) + else { + return Ok(None); + }; + let Some(height) = + xml_tag_attribute(&xml, "().ok()) + else { + return Ok(None); + }; + Ok(Some(Rect::from_emu(0, 0, width, height))) +} + fn zip_entry_string_if_exists( archive: &mut ZipArchive, path: &str, @@ -1223,7 +1337,11 @@ fn xml_attribute(tag: &str, attribute: &str) -> Option { } impl PresentationSlide { - fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent { + fn to_ppt_rs( + &self, + slide_size: Rect, + document: &PresentationDocument, + ) -> Result { let mut content = SlideContent::new("").layout(SlideLayout::Blank); if self.notes.visible && !self.notes.text.is_empty() { content = content.notes(&self.notes.text); @@ -1391,24 +1509,22 @@ impl PresentationSlide { content = content.table(builder.build()); } PresentationElement::Chart(chart) => { - let mut ppt_chart = Chart::new( - chart.title.as_deref().unwrap_or("Chart"), - chart.chart_type.to_ppt_rs(), - chart.categories, - points_to_emu(chart.frame.left), - points_to_emu(chart.frame.top), + let chart_bytes = render_chart_png_bytes(document, &chart)?; + let ppt_chart = Image::from_bytes( + chart_bytes, points_to_emu(chart.frame.width), points_to_emu(chart.frame.height), + "png", + ) + .position( + points_to_emu(chart.frame.left), + points_to_emu(chart.frame.top), ); - for series in chart.series { - ppt_chart = - ppt_chart.add_series(ChartSeries::new(&series.name, series.values)); - } - content = content.add_chart(ppt_chart); + content = content.add_image(ppt_chart); } } } - content + Ok(content) } } @@ -1477,6 +1593,45 @@ impl PresentationElement { Self::Chart(element) => element.z_order = z_order, } } + + fn visit_hyperlinks_mut(&mut self, visit: &mut F) + where + F: FnMut(&mut HyperlinkState) -> bool, + { + match self { + Self::Text(element) => { + if let Some(hyperlink) = element.hyperlink.as_mut() + && !visit(hyperlink) + { + element.hyperlink = None; + } + for range in &mut element.rich_text.ranges { + if let Some(hyperlink) = range.hyperlink.as_mut() + && !visit(hyperlink) + { + range.hyperlink = None; + } + } + } + Self::Shape(element) => { + if let Some(hyperlink) = element.hyperlink.as_mut() + && !visit(hyperlink) + { + element.hyperlink = None; + } + if let Some(rich_text) = element.rich_text.as_mut() { + for range in &mut rich_text.ranges { + if let Some(hyperlink) = range.hyperlink.as_mut() + && !visit(hyperlink) + { + range.hyperlink = None; + } + } + } + } + Self::Connector(_) | Self::Image(_) | Self::Table(_) | Self::Chart(_) => {} + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1724,34 +1879,6 @@ enum ChartTypeSpec { Combo, } -impl ChartTypeSpec { - fn to_ppt_rs(self) -> ChartType { - match self { - Self::Bar => ChartType::Bar, - Self::BarHorizontal => ChartType::BarHorizontal, - Self::BarStacked => ChartType::BarStacked, - Self::BarStacked100 => ChartType::BarStacked100, - Self::Line => ChartType::Line, - Self::LineMarkers => ChartType::LineMarkers, - Self::LineStacked => ChartType::LineStacked, - Self::Pie => ChartType::Pie, - Self::Doughnut => ChartType::Doughnut, - Self::Area => ChartType::Area, - Self::AreaStacked => ChartType::AreaStacked, - Self::AreaStacked100 => ChartType::AreaStacked100, - Self::Scatter => ChartType::Scatter, - Self::ScatterLines => ChartType::ScatterLines, - Self::ScatterSmooth => ChartType::ScatterSmooth, - Self::Bubble => ChartType::Bubble, - Self::Radar => ChartType::Radar, - Self::RadarFilled => ChartType::RadarFilled, - Self::StockHlc => ChartType::StockHLC, - Self::StockOhlc => ChartType::StockOHLC, - Self::Combo => ChartType::Combo, - } - } -} - impl ConnectorKind { fn to_ppt_rs(self) -> ConnectorType { match self { diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs index 58bfa43b91..431be0ffda 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/parsing.rs @@ -25,8 +25,11 @@ fn is_read_only_action(action: &str) -> bool { "get_summary" | "list_slides" | "list_layouts" + | "list_masters" | "list_layout_placeholders" | "list_slide_placeholders" + | "list_comment_threads" + | "get_comment_thread" | "inspect" | "resolve" | "to_proto" diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs index e4ff48f520..6fa9ea98ab 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/pptx.rs @@ -1,4 +1,9 @@ const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json"; +const DEFAULT_SLIDE_MASTER_TEXT_STYLES: &str = r#" + + + +"#; fn import_codex_metadata_document(path: &Path) -> Result, String> { let file = std::fs::File::open(path).map_err(|error| error.to_string())?; @@ -19,6 +24,7 @@ fn import_codex_metadata_document(path: &Path) -> Result Result, String> { let bytes = document .to_ppt_rs() + .map_err(|error| format!("{action}: {error}"))? .build() .map_err(|error| format!("{action}: {error}"))?; patch_pptx_package(bytes, document).map_err(|error| format!("{action}: {error}")) @@ -32,6 +38,18 @@ struct SlideImageAsset { extension: String, } +struct PictureXmlSpec<'a> { + description: Option<&'a str>, + lock_aspect_ratio: bool, + rotation_degrees: Option, + flip_horizontal: bool, + flip_vertical: bool, + shape_id: usize, + relationship_id: &'a str, + frame: Rect, + crop: Option, +} + fn normalized_image_extension(format: &str) -> String { match format.to_ascii_lowercase().as_str() { "jpeg" => "jpg".to_string(), @@ -46,13 +64,18 @@ fn image_relationship_xml(relationship_id: &str, target: &str) -> String { ) } -fn image_picture_xml( - image: &ImageElement, - shape_id: usize, - relationship_id: &str, - frame: Rect, - crop: Option, -) -> String { +fn picture_xml(spec: PictureXmlSpec<'_>) -> String { + let PictureXmlSpec { + description, + lock_aspect_ratio, + rotation_degrees, + flip_horizontal, + flip_vertical, + shape_id, + relationship_id, + frame, + crop, + } = spec; let blip_fill = if let Some((crop_left, crop_top, crop_right, crop_bottom)) = crop { format!( r#" @@ -77,22 +100,19 @@ fn image_picture_xml( "# ) }; - let descr = image - .alt_text - .as_deref() + let descr = description .map(|alt| format!(r#" descr="{}""#, ppt_rs::escape_xml(alt))) .unwrap_or_default(); - let no_change_aspect = if image.lock_aspect_ratio { 1 } else { 0 }; - let rotation = image - .rotation_degrees + let no_change_aspect = if lock_aspect_ratio { 1 } else { 0 }; + let rotation = rotation_degrees .map(|rotation| format!(r#" rot="{}""#, i64::from(rotation) * 60_000)) .unwrap_or_default(); - let flip_horizontal = if image.flip_horizontal { + let flip_horizontal = if flip_horizontal { r#" flipH="1""# } else { "" }; - let flip_vertical = if image.flip_vertical { + let flip_vertical = if flip_vertical { r#" flipV="1""# } else { "" @@ -124,10 +144,31 @@ fn image_picture_xml( ) } +fn image_picture_xml( + image: &ImageElement, + shape_id: usize, + relationship_id: &str, + frame: Rect, + crop: Option, +) -> String { + picture_xml(PictureXmlSpec { + description: image.alt_text.as_deref(), + lock_aspect_ratio: image.lock_aspect_ratio, + rotation_degrees: image.rotation_degrees, + flip_horizontal: image.flip_horizontal, + flip_vertical: image.flip_vertical, + shape_id, + relationship_id, + frame, + crop, + }) +} + fn slide_image_assets( + document: &PresentationDocument, slide: &PresentationSlide, next_media_index: &mut usize, -) -> Vec { +) -> Result, String> { let mut ordered = slide.elements.iter().collect::>(); ordered.sort_by_key(|element| element.z_order()); let shape_count = ordered @@ -145,51 +186,84 @@ fn slide_image_assets( let mut image_index = 0_usize; let mut assets = Vec::new(); for element in ordered { - let PresentationElement::Image(image) = element else { - continue; - }; - let Some(payload) = &image.payload else { - continue; - }; - let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch { - fit_image(image) - } else { - ( - image.frame.left, - image.frame.top, - image.frame.width, - image.frame.height, - None, - ) - }; - image_index += 1; - let relationship_id = format!("rIdImage{image_index}"); - let extension = normalized_image_extension(&payload.format); - let media_name = format!("image{next_media_index}.{extension}"); - *next_media_index += 1; - assets.push(SlideImageAsset { - xml: image_picture_xml( - image, - 20 + shape_count + image_index - 1, - &relationship_id, - Rect { - left, - top, - width, - height, - }, - image.crop.or(fitted_crop), - ), - relationship_xml: image_relationship_xml( - &relationship_id, - &format!("../media/{media_name}"), - ), - media_path: format!("ppt/media/{media_name}"), - media_bytes: payload.bytes.clone(), - extension, - }); + match element { + PresentationElement::Image(image) => { + let Some(payload) = &image.payload else { + continue; + }; + let (left, top, width, height, fitted_crop) = if image.fit_mode != ImageFitMode::Stretch { + fit_image(image) + } else { + ( + image.frame.left, + image.frame.top, + image.frame.width, + image.frame.height, + None, + ) + }; + image_index += 1; + let relationship_id = format!("rIdImage{image_index}"); + let extension = normalized_image_extension(&payload.format); + let media_name = format!("image{next_media_index}.{extension}"); + *next_media_index += 1; + assets.push(SlideImageAsset { + xml: image_picture_xml( + image, + 20 + shape_count + image_index - 1, + &relationship_id, + Rect { + left, + top, + width, + height, + }, + image.crop.or(fitted_crop), + ), + relationship_xml: image_relationship_xml( + &relationship_id, + &format!("../media/{media_name}"), + ), + media_path: format!("ppt/media/{media_name}"), + media_bytes: payload.bytes.clone(), + extension, + }); + } + PresentationElement::Chart(chart) => { + image_index += 1; + let relationship_id = format!("rIdImage{image_index}"); + let extension = "png".to_string(); + let media_name = format!("image{next_media_index}.{extension}"); + *next_media_index += 1; + assets.push(SlideImageAsset { + xml: picture_xml(PictureXmlSpec { + description: chart.title.as_deref(), + lock_aspect_ratio: true, + rotation_degrees: None, + flip_horizontal: false, + flip_vertical: false, + shape_id: 20 + shape_count + image_index - 1, + relationship_id: &relationship_id, + frame: chart.frame, + crop: None, + }), + relationship_xml: image_relationship_xml( + &relationship_id, + &format!("../media/{media_name}"), + ), + media_path: format!("ppt/media/{media_name}"), + media_bytes: render_chart_png_bytes(document, chart) + .map_err(|error| error.to_string())?, + extension, + }); + } + PresentationElement::Text(_) + | PresentationElement::Shape(_) + | PresentationElement::Connector(_) + | PresentationElement::Table(_) => {} + } } - assets + Ok(assets) } fn patch_pptx_package( @@ -201,17 +275,22 @@ fn patch_pptx_package( let mut writer = ZipWriter::new(Cursor::new(Vec::new())); let mut next_media_index = 1_usize; let mut pending_slide_relationships = HashMap::new(); + let mut pending_notes_relationships = HashMap::new(); let mut pending_slide_images = HashMap::new(); let mut pending_media = Vec::new(); let mut image_extensions = BTreeSet::new(); for (slide_index, slide) in document.slides.iter().enumerate() { let slide_number = slide_index + 1; - let images = slide_image_assets(slide, &mut next_media_index); + let images = slide_image_assets(document, slide, &mut next_media_index)?; let mut relationships = slide_hyperlink_relationships(slide); relationships.extend(images.iter().map(|image| image.relationship_xml.clone())); if !relationships.is_empty() { pending_slide_relationships.insert(slide_number, relationships); } + let notes_relationships = notes_hyperlink_relationships(slide); + if !notes_relationships.is_empty() { + pending_notes_relationships.insert(slide_number, notes_relationships); + } if !images.is_empty() { image_extensions.extend(images.iter().map(|image| image.extension.clone())); pending_media.extend( @@ -253,6 +332,18 @@ fn patch_pptx_package( .map_err(|error| error.to_string())?; continue; } + if name == "ppt/theme/theme1.xml" { + writer + .write_all(update_theme_xml(bytes, &document.theme)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } + if name == "ppt/slideMasters/slideMaster1.xml" { + writer + .write_all(update_slide_master_xml(bytes)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } if let Some(slide_number) = parse_slide_xml_path(&name) { writer .write_all( @@ -277,6 +368,23 @@ fn patch_pptx_package( .map_err(|error| error.to_string())?; continue; } + if let Some(slide_number) = parse_notes_slide_xml_path(&name) { + writer + .write_all( + update_notes_slide_xml(bytes, &document.slides[slide_number - 1], document)? + .as_bytes(), + ) + .map_err(|error| error.to_string())?; + continue; + } + if let Some(slide_number) = parse_notes_slide_relationships_path(&name) + && let Some(relationships) = pending_notes_relationships.remove(&slide_number) + { + writer + .write_all(update_slide_relationships_xml(bytes, &relationships)?.as_bytes()) + .map_err(|error| error.to_string())?; + continue; + } writer .write_all(&bytes) .map_err(|error| error.to_string())?; @@ -294,6 +402,18 @@ fn patch_pptx_package( .map_err(|error| error.to_string())?; } + for (slide_number, relationships) in pending_notes_relationships { + writer + .start_file( + format!("ppt/notesSlides/_rels/notesSlide{slide_number}.xml.rels"), + SimpleFileOptions::default(), + ) + .map_err(|error| error.to_string())?; + writer + .write_all(slide_relationships_xml(&relationships).as_bytes()) + .map_err(|error| error.to_string())?; + } + for (path, bytes) in pending_media { writer .start_file(path, SimpleFileOptions::default()) @@ -343,6 +463,24 @@ fn update_presentation_xml_dimensions( ) } +fn update_slide_master_xml(existing_bytes: Vec) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + if existing.contains("") { + return Ok(existing); + } + + let closing_tag = ""; + let start = existing + .find(closing_tag) + .ok_or_else(|| "slide master xml is missing ``".to_string())?; + Ok(format!( + "{}{}{}", + &existing[..start], + DEFAULT_SLIDE_MASTER_TEXT_STYLES, + &existing[start..] + )) +} + fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result { let start = xml .find(&format!("<{tag} ")) @@ -354,29 +492,180 @@ fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Resu Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) } +fn update_theme_xml(existing_bytes: Vec, theme: &ThemeState) -> Result { + let mut existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + + if let Some(major_font) = theme_font_value(theme, &["major_font"]) { + existing = replace_theme_font(existing, "majorFont", &major_font)?; + } + if let Some(minor_font) = theme_font_value(theme, &["minor_font"]) { + existing = replace_theme_font(existing, "minorFont", &minor_font)?; + } + + for (tag, keys) in [ + ("dk1", &["dk1", "tx1", "text1", "dark1"][..]), + ("lt1", &["lt1", "bg1", "background1", "light1"][..]), + ("dk2", &["dk2", "tx2", "text2", "dark2"][..]), + ("lt2", &["lt2", "bg2", "background2", "light2"][..]), + ("accent1", &["accent1"][..]), + ("accent2", &["accent2"][..]), + ("accent3", &["accent3"][..]), + ("accent4", &["accent4"][..]), + ("accent5", &["accent5"][..]), + ("accent6", &["accent6"][..]), + ("hlink", &["hlink"][..]), + ("folHlink", &["folhlink"][..]), + ] { + if let Some(color) = theme_color_value(theme, keys) { + existing = replace_theme_color(existing, tag, &color)?; + } + } + + Ok(existing) +} + +fn theme_font_value(theme: &ThemeState, keys: &[&str]) -> Option { + keys.iter().find_map(|key| match *key { + "major_font" => theme.major_font.clone(), + "minor_font" => theme.minor_font.clone(), + _ => None, + }) +} + +fn theme_color_value(theme: &ThemeState, keys: &[&str]) -> Option { + keys.iter().find_map(|key| { + theme.color_scheme.get(*key).map(|value| { + value + .trim_start_matches('#') + .trim() + .to_ascii_uppercase() + }) + }) +} + +fn replace_theme_font(xml: String, tag: &str, font: &str) -> Result { + let start_tag = format!(""); + let end_tag = format!(""); + let start = xml + .find(&start_tag) + .ok_or_else(|| format!("theme xml is missing ``"))?; + let end = xml[start..] + .find(&end_tag) + .map(|offset| start + offset + end_tag.len()) + .ok_or_else(|| format!("theme xml is missing ``"))?; + let replacement = format!( + r#" + + + + "#, + ppt_rs::escape_xml(font) + ); + Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) +} + +fn replace_theme_color(xml: String, tag: &str, color: &str) -> Result { + let start_tag = format!(""); + let start = xml + .find(&start_tag) + .ok_or_else(|| format!("theme xml is missing ``"))?; + let end = xml[start..] + .find(&end_tag) + .map(|offset| start + offset + end_tag.len()) + .ok_or_else(|| format!("theme xml is missing ``"))?; + let replacement = format!(r#""#); + Ok(format!("{}{replacement}{}", &xml[..start], &xml[end..])) +} + fn slide_hyperlink_relationships(slide: &PresentationSlide) -> Vec { let mut ordered = slide.elements.iter().collect::>(); ordered.sort_by_key(|element| element.z_order()); - let mut hyperlink_index = 1_u32; + let mut relationship_ids = HashSet::new(); let mut relationships = Vec::new(); for element in ordered { - let Some(hyperlink) = (match element { - PresentationElement::Text(text) => text.hyperlink.as_ref(), - PresentationElement::Shape(shape) => shape.hyperlink.as_ref(), + match element { + PresentationElement::Text(text) => { + if let Some(hyperlink) = &text.hyperlink { + let relationship_id = format!("rIdHyperlink_{}", text.element_id); + if relationship_ids.insert(relationship_id.clone()) { + relationships.push(hyperlink.relationship_xml(&relationship_id)); + } + } + collect_rich_text_hyperlinks( + &mut relationships, + &mut relationship_ids, + &text.element_id, + &text.rich_text, + ); + } + PresentationElement::Shape(shape) => { + if let Some(hyperlink) = &shape.hyperlink { + let relationship_id = format!("rIdHyperlink_{}", shape.element_id); + if relationship_ids.insert(relationship_id.clone()) { + relationships.push(hyperlink.relationship_xml(&relationship_id)); + } + } + if let Some(rich_text) = &shape.rich_text { + collect_rich_text_hyperlinks( + &mut relationships, + &mut relationship_ids, + &shape.element_id, + rich_text, + ); + } + } + PresentationElement::Table(table) => { + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + collect_rich_text_hyperlinks( + &mut relationships, + &mut relationship_ids, + &table_cell_export_id(&table.element_id, row_index, column_index), + &cell.rich_text, + ); + } + } + } PresentationElement::Connector(_) | PresentationElement::Image(_) - | PresentationElement::Table(_) - | PresentationElement::Chart(_) => None, - }) else { - continue; - }; - let relationship_id = format!("rIdHyperlink{hyperlink_index}"); - hyperlink_index += 1; - relationships.push(hyperlink.relationship_xml(&relationship_id)); + | PresentationElement::Chart(_) => {} + } } relationships } +fn collect_rich_text_hyperlinks( + relationships: &mut Vec, + relationship_ids: &mut HashSet, + element_id: &str, + rich_text: &RichTextState, +) { + for range in &rich_text.ranges { + let Some(hyperlink) = &range.hyperlink else { + continue; + }; + let relationship_id = rich_text_relationship_id(element_id, &range.range_id); + if relationship_ids.insert(relationship_id.clone()) { + relationships.push(hyperlink.relationship_xml(&relationship_id)); + } + } +} + +fn rich_text_relationship_id(element_id: &str, range_id: &str) -> String { + let sanitize = |value: &str| { + value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) + .collect::() + }; + format!("rIdRange_{}_{}", sanitize(element_id), sanitize(range_id)) +} + +fn table_cell_export_id(table_id: &str, row_index: usize, column_index: usize) -> String { + format!("{table_id}_r{row_index}_c{column_index}") +} + fn parse_slide_relationships_path(path: &str) -> Option { path.strip_prefix("ppt/slides/_rels/slide")? .strip_suffix(".xml.rels")? @@ -391,6 +680,20 @@ fn parse_slide_xml_path(path: &str) -> Option { .ok() } +fn parse_notes_slide_xml_path(path: &str) -> Option { + path.strip_prefix("ppt/notesSlides/notesSlide")? + .strip_suffix(".xml")? + .parse::() + .ok() +} + +fn parse_notes_slide_relationships_path(path: &str) -> Option { + path.strip_prefix("ppt/notesSlides/_rels/notesSlide")? + .strip_suffix(".xml.rels")? + .parse::() + .ok() +} + fn update_slide_relationships_xml( existing_bytes: Vec, relationships: &[String], @@ -451,7 +754,7 @@ fn update_slide_xml( let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; let existing = replace_image_placeholders(existing, slide_images)?; let existing = apply_shape_block_patches(existing, slide)?; - let table_xml = slide_table_xml(slide); + let table_xml = slide_table_xml(slide)?; if table_xml.is_empty() { return Ok(existing); } @@ -461,6 +764,57 @@ fn update_slide_xml( .ok_or_else(|| "slide xml is missing a closing ``".to_string()) } +fn update_notes_slide_xml( + existing_bytes: Vec, + slide: &PresentationSlide, + document: &PresentationDocument, +) -> Result { + let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?; + if slide.notes.text.is_empty() { + return Ok(existing); + } + patch_notes_body_text( + existing, + &export_notes_text_body_xml( + &format!("notes_{}", slide.slide_id), + &slide.notes, + document, + ), + ) +} + +fn notes_hyperlink_relationships(slide: &PresentationSlide) -> Vec { + if !slide.notes.visible || slide.notes.text.is_empty() { + return Vec::new(); + } + let mut relationship_ids = HashSet::new(); + let mut relationships = Vec::new(); + collect_rich_text_hyperlinks( + &mut relationships, + &mut relationship_ids, + &format!("notes_{}", slide.slide_id), + &slide.notes.rich_text, + ); + relationships +} + +fn patch_notes_body_text(existing: String, text_body_xml: &str) -> Result { + let placeholder_marker = "") + .ok_or_else(|| "notes xml is missing an opening `` for the body placeholder".to_string())?; + let end = existing[marker..] + .find("") + .map(|offset| marker + offset + "".len()) + .ok_or_else(|| "notes xml is missing a closing `` for the body placeholder".to_string())?; + let block = &existing[start..end]; + let patched = patch_shape_block_text(block, text_body_xml)?; + Ok(format!("{}{}{}", &existing[..start], patched, &existing[end..])) +} + fn replace_image_placeholders( existing: String, slide_images: &[SlideImageAsset], @@ -493,11 +847,12 @@ fn replace_image_placeholders( Ok(updated) } -#[derive(Clone, Copy)] +#[derive(Clone)] struct ShapeXmlPatch { line_style: Option, flip_horizontal: bool, flip_vertical: bool, + text_body_xml: Option, } fn apply_shape_block_patches( @@ -512,16 +867,32 @@ fn apply_shape_block_patches( ordered.sort_by_key(|element| element.z_order()); for element in ordered { match element { - PresentationElement::Text(_) => patches.push(None), - PresentationElement::Shape(shape) => patches.push(Some(ShapeXmlPatch { - line_style: shape - .stroke - .as_ref() - .map(|stroke| stroke.style) - .filter(|style| *style != LineStyle::Solid), - flip_horizontal: shape.flip_horizontal, - flip_vertical: shape.flip_vertical, + PresentationElement::Text(text) => patches.push(Some(ShapeXmlPatch { + line_style: None, + flip_horizontal: false, + flip_vertical: false, + text_body_xml: Some(export_text_body_xml( + &text.element_id, + &text.text, + &text.style, + &text.rich_text, + )), })), + PresentationElement::Shape(shape) => { + let rich_text = shape.rich_text.clone().unwrap_or_default(); + patches.push(Some(ShapeXmlPatch { + line_style: shape + .stroke + .as_ref() + .map(|stroke| stroke.style) + .filter(|style| *style != LineStyle::Solid), + flip_horizontal: shape.flip_horizontal, + flip_vertical: shape.flip_vertical, + text_body_xml: shape.text.as_ref().map(|text| { + export_text_body_xml(&shape.element_id, text, &shape.text_style, &rich_text) + }), + })); + } PresentationElement::Image(ImageElement { payload: None, .. }) => patches.push(None), PresentationElement::Connector(_) | PresentationElement::Image(_) @@ -530,8 +901,11 @@ fn apply_shape_block_patches( } } if patches.iter().all(|patch| { - patch.is_none_or(|patch| { - patch.line_style.is_none() && !patch.flip_horizontal && !patch.flip_vertical + patch.as_ref().is_none_or(|patch| { + patch.line_style.is_none() + && !patch.flip_horizontal + && !patch.flip_vertical + && patch.text_body_xml.is_none() }) }) { return Ok(existing); @@ -561,11 +935,16 @@ fn apply_shape_block_patches( } fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result { - let block = if let Some(line_style) = patch.line_style { - patch_shape_block_dash(block, line_style)? + let block = if let Some(text_body_xml) = patch.text_body_xml { + patch_shape_block_text(block, &text_body_xml)? } else { block.to_string() }; + let block = if let Some(line_style) = patch.line_style { + patch_shape_block_dash(&block, line_style)? + } else { + block + }; if patch.flip_horizontal || patch.flip_vertical { patch_shape_block_flip(&block, patch.flip_horizontal, patch.flip_vertical) } else { @@ -573,6 +952,25 @@ fn patch_shape_block(block: &str, patch: ShapeXmlPatch) -> Result Result { + if let Some(text_start) = block.find("") { + let text_end = block[text_start..] + .find("") + .map(|offset| text_start + offset + "".len()) + .ok_or_else(|| "shape text body is missing a closing ``".to_string())?; + let mut patched = String::with_capacity(block.len() + text_body_xml.len()); + patched.push_str(&block[..text_start]); + patched.push_str(text_body_xml); + patched.push_str(&block[text_end..]); + Ok(patched) + } else { + block + .find("") + .map(|index| format!("{}{text_body_xml}{}", &block[..index], &block[index..])) + .ok_or_else(|| "shape block is missing a closing ``".to_string()) + } +} + fn patch_shape_block_dash(block: &str, line_style: LineStyle) -> Result { let Some(line_start) = block.find("` entry for stroke styling".to_string()); @@ -652,6 +1050,361 @@ fn patch_shape_block_flip( )) } +fn export_text_body_xml( + element_id: &str, + text: &str, + style: &TextStyle, + rich_text: &RichTextState, +) -> String { + let left_inset_attr = concat!("l", "Ins"); + let right_inset_attr = concat!("r", "Ins"); + let top_inset_attr = concat!("t", "Ins"); + let bottom_inset_attr = concat!("b", "Ins"); + let insets = rich_text.layout.insets.unwrap_or(TextInsets { + left: 6, + right: 6, + top: 6, + bottom: 6, + }); + let wrap = match rich_text.layout.wrap.unwrap_or(TextWrapMode::Square) { + TextWrapMode::Square => "square", + TextWrapMode::None => "none", + }; + let anchor = match rich_text + .layout + .vertical_alignment + .unwrap_or(TextVerticalAlignment::Top) + { + TextVerticalAlignment::Top => "t", + TextVerticalAlignment::Middle => "ctr", + TextVerticalAlignment::Bottom => "b", + }; + let auto_fit = match rich_text.layout.auto_fit.unwrap_or(TextAutoFitMode::None) { + TextAutoFitMode::None => String::new(), + TextAutoFitMode::ShrinkText => "".to_string(), + TextAutoFitMode::ResizeShapeToFitText => "".to_string(), + }; + let paragraphs = export_text_paragraphs_xml( + element_id, + text, + style, + style.alignment.unwrap_or(TextAlignment::Left), + rich_text, + ); + let left_inset = points_to_emu(insets.left); + let right_inset = points_to_emu(insets.right); + let top_inset = points_to_emu(insets.top); + let bottom_inset = points_to_emu(insets.bottom); + format!( + r#"{auto_fit}{paragraphs}"# + ) +} + +fn export_notes_text_body_xml( + element_id: &str, + notes: &NotesState, + document: &PresentationDocument, +) -> String { + let style = TextStyle { + font_size: Some(12), + font_family: document.theme.minor_font.clone(), + color: document.theme.resolve_color("tx1"), + ..TextStyle::default() + }; + export_text_body_xml(element_id, ¬es.text, &style, ¬es.rich_text) +} + +fn export_table_cell_text_body_xml( + element_id: &str, + cell: &TableCellSpec, +) -> String { + let left_inset_attr = concat!("l", "Ins"); + let right_inset_attr = concat!("r", "Ins"); + let top_inset_attr = concat!("t", "Ins"); + let bottom_inset_attr = concat!("b", "Ins"); + let wrap = match cell.rich_text.layout.wrap.unwrap_or(TextWrapMode::Square) { + TextWrapMode::Square => "square", + TextWrapMode::None => "none", + }; + let anchor = match cell + .rich_text + .layout + .vertical_alignment + .unwrap_or(TextVerticalAlignment::Top) + { + TextVerticalAlignment::Top => "t", + TextVerticalAlignment::Middle => "ctr", + TextVerticalAlignment::Bottom => "b", + }; + let auto_fit = match cell + .rich_text + .layout + .auto_fit + .unwrap_or(TextAutoFitMode::None) + { + TextAutoFitMode::None => String::new(), + TextAutoFitMode::ShrinkText => "".to_string(), + TextAutoFitMode::ResizeShapeToFitText => "".to_string(), + }; + let insets = cell.rich_text.layout.insets.map(|insets| { + let left_inset = points_to_emu(insets.left); + let right_inset = points_to_emu(insets.right); + let top_inset = points_to_emu(insets.top); + let bottom_inset = points_to_emu(insets.bottom); + format!( + r#" {left_inset_attr}="{left_inset}" {right_inset_attr}="{right_inset}" {top_inset_attr}="{top_inset}" {bottom_inset_attr}="{bottom_inset}""#, + ) + }); + let paragraphs = export_text_paragraphs_xml( + element_id, + &cell.text, + &cell.text_style, + cell.alignment + .or(cell.text_style.alignment) + .unwrap_or(TextAlignment::Left), + &cell.rich_text, + ); + format!( + r#"{auto_fit}{paragraphs}"#, + insets.unwrap_or_default(), + ) +} + +fn export_text_paragraphs_xml( + element_id: &str, + text: &str, + style: &TextStyle, + alignment: TextAlignment, + rich_text: &RichTextState, +) -> String { + let chars = text.chars().collect::>(); + let mut styles = vec![style.clone(); chars.len()]; + let mut hyperlinks = vec![None; chars.len()]; + for range in &rich_text.ranges { + let start = range.start_cp.min(chars.len()); + let end = range.start_cp.saturating_add(range.length).min(chars.len()); + for entry in styles.iter_mut().skip(start).take(end.saturating_sub(start)) { + *entry = merged_text_style(entry, &range.style); + } + if let Some(hyperlink) = &range.hyperlink { + let relationship_id = rich_text_relationship_id(element_id, &range.range_id); + for entry in hyperlinks + .iter_mut() + .skip(start) + .take(end.saturating_sub(start)) + { + *entry = Some((relationship_id.clone(), hyperlink.clone())); + } + } + } + + let alignment = match alignment { + TextAlignment::Left => "l", + TextAlignment::Center => "ctr", + TextAlignment::Right => "r", + TextAlignment::Justify => "just", + }; + + let mut paragraphs = Vec::new(); + let mut paragraph_runs: Vec = Vec::new(); + let mut current_text = String::new(); + let mut current_style: Option = None; + let mut current_hyperlink: Option<(String, HyperlinkState)> = None; + let mut paragraph_start = 0_usize; + + let flush_run = |paragraph_runs: &mut Vec, + current_text: &mut String, + current_style: &mut Option, + current_hyperlink: &mut Option<(String, HyperlinkState)>| { + if let Some(style) = current_style.take() + && !current_text.is_empty() + { + paragraph_runs.push(TextRunExport { + text: std::mem::take(current_text), + style, + hyperlink: current_hyperlink.take(), + }); + } + }; + + for (index, ch) in chars.iter().enumerate() { + if *ch == '\n' { + flush_run( + &mut paragraph_runs, + &mut current_text, + &mut current_style, + &mut current_hyperlink, + ); + paragraphs.push(render_text_paragraph_xml( + ¶graph_runs, + alignment, + paragraph_spacing_xml(rich_text, paragraph_start, index), + )); + paragraph_runs.clear(); + paragraph_start = index + 1; + continue; + } + + let next_style = styles[index].clone(); + let next_hyperlink = hyperlinks[index].clone(); + match ¤t_style { + Some(existing) + if text_style_eq(existing, &next_style) && current_hyperlink == next_hyperlink => + { + current_text.push(*ch) + } + Some(_) => { + flush_run( + &mut paragraph_runs, + &mut current_text, + &mut current_style, + &mut current_hyperlink, + ); + current_style = Some(next_style); + current_hyperlink = next_hyperlink; + current_text.push(*ch); + } + None => { + current_style = Some(next_style); + current_hyperlink = next_hyperlink; + current_text.push(*ch); + } + } + } + flush_run( + &mut paragraph_runs, + &mut current_text, + &mut current_style, + &mut current_hyperlink, + ); + if !paragraph_runs.is_empty() || paragraphs.is_empty() { + paragraphs.push(render_text_paragraph_xml( + ¶graph_runs, + alignment, + paragraph_spacing_xml(rich_text, paragraph_start, chars.len()), + )); + } + paragraphs.join("") +} + +#[derive(Clone)] +struct TextRunExport { + text: String, + style: TextStyle, + hyperlink: Option<(String, HyperlinkState)>, +} + +fn render_text_paragraph_xml( + runs: &[TextRunExport], + alignment: &str, + spacing_xml: String, +) -> String { + if runs.is_empty() { + return format!(r#"{spacing_xml}"#); + } + let runs_xml = runs + .iter() + .map(render_text_run_xml) + .collect::(); + format!(r#"{spacing_xml}{runs_xml}"#) +} + +fn render_text_run_xml(run: &TextRunExport) -> String { + let TextRunExport { + text, + style, + hyperlink, + } = run; + let mut attrs = vec!["lang=\"en-US\"".to_string(), "dirty=\"0\"".to_string()]; + if let Some(font_size) = style.font_size { + attrs.push(format!(r#"sz="{}""#, font_size * 100)); + } + if style.bold { + attrs.push(r#"b="1""#.to_string()); + } + if style.italic { + attrs.push(r#"i="1""#.to_string()); + } + if style.underline { + attrs.push(r#"u="sng""#.to_string()); + } + + let mut children = String::new(); + if let Some(color) = &style.color { + children.push_str(&format!( + r#""#, + color.trim_start_matches('#').to_ascii_uppercase() + )); + } + if let Some(font_family) = &style.font_family { + let escaped = ppt_rs::escape_xml(font_family); + children.push_str(&format!( + r#""# + )); + } + if let Some((relationship_id, hyperlink)) = hyperlink { + let tooltip = hyperlink + .tooltip + .as_ref() + .map(|tooltip| format!(r#" tooltip="{}""#, ppt_rs::escape_xml(tooltip))) + .unwrap_or_default(); + let highlight = if hyperlink.highlight_click { "1" } else { "0" }; + children.push_str(&format!( + r#""# + )); + } + + format!( + r#"{children}{}"#, + attrs.join(" "), + ppt_rs::escape_xml(text) + ) +} + +fn paragraph_spacing_xml(rich_text: &RichTextState, start: usize, end: usize) -> String { + let mut spacing_before = None; + let mut spacing_after = None; + let mut line_spacing = None; + for range in &rich_text.ranges { + let range_end = range.start_cp.saturating_add(range.length); + if range.start_cp >= end || range_end <= start { + continue; + } + if spacing_before.is_none() { + spacing_before = range.spacing_before; + } + if spacing_after.is_none() { + spacing_after = range.spacing_after; + } + if line_spacing.is_none() { + line_spacing = range.line_spacing; + } + } + + let mut xml = String::new(); + if let Some(value) = spacing_before { + xml.push_str(&format!(r#""#)); + } + if let Some(value) = spacing_after { + xml.push_str(&format!(r#""#)); + } + if let Some(value) = line_spacing { + let spacing = (value * 100_000.0).round() as i32; + xml.push_str(&format!(r#""#)); + } + xml +} + +fn text_style_eq(left: &TextStyle, right: &TextStyle) -> bool { + left.font_size == right.font_size + && left.font_family == right.font_family + && left.color == right.color + && left.alignment == right.alignment + && left.bold == right.bold + && left.italic == right.italic + && left.underline == right.underline +} + fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String { let needle = format!(r#"{attribute}=""#); if let Some(start) = tag.find(&needle) { @@ -665,54 +1418,112 @@ fn upsert_xml_attribute(tag: &str, attribute: &str, value: &str) -> String { format!(r#"{} {attribute}="{value}""#, &tag[..insert_at]) + &tag[insert_at..] } -fn slide_table_xml(slide: &PresentationSlide) -> String { +fn slide_table_xml(slide: &PresentationSlide) -> Result { let mut ordered = slide.elements.iter().collect::>(); ordered.sort_by_key(|element| element.z_order()); let mut table_index = 0_usize; - ordered - .into_iter() - .filter_map(|element| { - let PresentationElement::Table(table) = element else { - return None; + let mut xml = Vec::new(); + for element in ordered { + let PresentationElement::Table(table) = element else { + continue; + }; + table_index += 1; + let rows = table + .rows + .clone() + .into_iter() + .enumerate() + .map(|(row_index, row)| { + let cells = row + .into_iter() + .enumerate() + .map(|(column_index, cell)| { + build_table_cell(cell, &table.merges, row_index, column_index) + }) + .collect::>(); + let mut table_row = TableRow::new(cells); + if let Some(height) = table.row_heights.get(row_index) { + table_row = table_row.with_height(points_to_emu(*height)); + } + Some(table_row) + }) + .collect::>>() + .ok_or_else(|| "table export failed to build rows".to_string())?; + let table_xml = ppt_rs::generator::table::generate_table_xml( + &ppt_rs::generator::table::Table::new( + rows, + table + .column_widths + .iter() + .copied() + .map(points_to_emu) + .collect(), + points_to_emu(table.frame.left), + points_to_emu(table.frame.top), + ), + 300 + table_index, + ); + xml.push(patch_table_cell_text_bodies(table_xml, table)?); + } + Ok(xml.join("\n")) +} + +fn patch_table_cell_text_bodies(table_xml: String, table: &TableElement) -> Result { + let mut updated = String::with_capacity(table_xml.len()); + let mut remaining = table_xml.as_str(); + for (row_index, row) in table.rows.iter().enumerate() { + for (column_index, cell) in row.iter().enumerate() { + let Some(start) = remaining.find("` block".to_string()); }; - table_index += 1; - let rows = table - .rows - .clone() - .into_iter() - .enumerate() - .map(|(row_index, row)| { - let cells = row - .into_iter() - .enumerate() - .map(|(column_index, cell)| { - build_table_cell(cell, &table.merges, row_index, column_index) - }) - .collect::>(); - let mut table_row = TableRow::new(cells); - if let Some(height) = table.row_heights.get(row_index) { - table_row = table_row.with_height(points_to_emu(*height)); - } - Some(table_row) - }) - .collect::>>()?; - Some(ppt_rs::generator::table::generate_table_xml( - &ppt_rs::generator::table::Table::new( - rows, - table - .column_widths - .iter() - .copied() - .map(points_to_emu) - .collect(), - points_to_emu(table.frame.left), - points_to_emu(table.frame.top), - ), - 300 + table_index, - )) - }) - .collect::>() - .join("\n") + let end = remaining[start..] + .find("") + .map(|offset| start + offset + "".len()) + .ok_or_else(|| "table xml is missing a closing `` block".to_string())?; + updated.push_str(&remaining[..start]); + let block = &remaining[start..end]; + if table_cell_is_merged(table, row_index, column_index) { + updated.push_str(block); + } else { + updated.push_str(&patch_table_cell_text( + block, + &export_table_cell_text_body_xml( + &table_cell_export_id(&table.element_id, row_index, column_index), + cell, + ), + )?); + } + remaining = &remaining[end..]; + } + } + updated.push_str(remaining); + Ok(updated) +} + +fn table_cell_is_merged(table: &TableElement, row_index: usize, column_index: usize) -> bool { + table.merges.iter().any(|merge| { + row_index >= merge.start_row + && row_index <= merge.end_row + && column_index >= merge.start_column + && column_index <= merge.end_column + && (row_index != merge.start_row || column_index != merge.start_column) + }) +} + +fn patch_table_cell_text(block: &str, text_body_xml: &str) -> Result { + if let Some(text_start) = block.find("") { + let text_end = block[text_start..] + .find("") + .map(|offset| text_start + offset + "".len()) + .ok_or_else(|| "table cell text body is missing a closing ``".to_string())?; + let mut patched = String::with_capacity(block.len() + text_body_xml.len()); + patched.push_str(&block[..text_start]); + patched.push_str(text_body_xml); + patched.push_str(&block[text_end..]); + Ok(patched) + } else { + Err("table cell block is missing an `` entry".to_string()) + } } pub(crate) fn write_preview_image_bytes( diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs index 05741303ac..c7cd4cabda 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/proto.rs @@ -472,7 +472,7 @@ fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) -> }) } -fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value { +pub(super) fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value { serde_json::json!({ "displayName": author.display_name, "initials": author.initials, @@ -480,7 +480,7 @@ fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value { }) } -fn comment_thread_to_proto(thread: &CommentThread) -> Value { +pub(super) fn comment_thread_to_proto(thread: &CommentThread) -> Value { serde_json::json!({ "kind": "comment", "threadId": thread.thread_id, diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/render.rs b/codex-rs/artifact-presentation/src/presentation_artifact/render.rs index 22da641013..01fab77dee 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/render.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/render.rs @@ -1,9 +1,11 @@ use font8x8::BASIC_FONTS; use font8x8::UnicodeFonts; +use fontdue::Font; use image::DynamicImage; use image::Rgba; use image::RgbaImage; use std::f32::consts::PI; +use std::sync::{Arc, Mutex, OnceLock}; use tiny_skia::FillRule; use tiny_skia::LineCap; use tiny_skia::LineJoin; @@ -22,6 +24,19 @@ const DEFAULT_TABLE_GRID_HEX: &str = "BFC5CC"; const DEFAULT_COMMENT_HEX: &str = "F3C83C"; const DEFAULT_TEXT_INSET: u32 = 6; const MIN_TEXT_PIXELS: u32 = 8; +const PREVIEW_SUPERSAMPLE_SCALE: f32 = 2.0; +const CHART_EXPORT_SCALE: f32 = 2.0; +const DEFAULT_PREVIEW_FONT_SIZE_RATIO: f32 = 0.85; +const PREVIEW_FONT_DIRS: [&str; 3] = [ + "/Library/Fonts", + "/System/Library/Fonts/Supplemental", + "/System/Library/Fonts", +]; +const PREVIEW_FONT_FALLBACKS: [&str; 5] = + ["Arial", "Helvetica", "Avenir", "Times", "Courier"]; + +static PREVIEW_FONT_CACHE: OnceLock>>>> = + OnceLock::new(); #[derive(Debug, Clone, Copy)] pub(crate) struct RenderOptions { @@ -47,6 +62,7 @@ struct TextRenderConfig { width: u32, font_px: u32, line_height: u32, + baseline_offset: u32, wrap: TextWrapMode, vertical_alignment: TextVerticalAlignment, alignment: TextAlignment, @@ -72,12 +88,57 @@ fn render_slide_png( }); } + let target_width = scaled_dimension(document.slide_size.width, options.scale); + let target_height = scaled_dimension(document.slide_size.height, options.scale); + let render_scale = options.scale * PREVIEW_SUPERSAMPLE_SCALE; + let mut canvas = render_slide_image(document, slide_index, options, render_scale)?; + if PREVIEW_SUPERSAMPLE_SCALE > 1.0 && (canvas.width() != target_width || canvas.height() != target_height) + { + canvas = image::imageops::resize( + &canvas, + target_width.max(1), + target_height.max(1), + image::imageops::FilterType::Lanczos3, + ); + } + + let mut output = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(canvas) + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|error| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: error.to_string(), + })?; + Ok(output.into_inner()) +} + +fn render_chart_png_bytes( + document: &PresentationDocument, + chart: &ChartElement, +) -> Result, PresentationArtifactError> { + let rendered = render_chart_element(chart, document, CHART_EXPORT_SCALE)?; + let mut output = std::io::Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(rendered) + .write_to(&mut output, image::ImageFormat::Png) + .map_err(|error| PresentationArtifactError::RenderFailed { + action: "export_pptx".to_string(), + message: error.to_string(), + })?; + Ok(output.into_inner()) +} + +fn render_slide_image( + document: &PresentationDocument, + slide_index: usize, + options: RenderOptions, + render_scale: f32, +) -> Result { let slide = document .slides .get(slide_index) .ok_or_else(|| index_out_of_range("render_preview", slide_index, document.slides.len()))?; - let width = scaled_dimension(document.slide_size.width, options.scale); - let height = scaled_dimension(document.slide_size.height, options.scale); + let width = scaled_dimension(document.slide_size.width, render_scale); + let height = scaled_dimension(document.slide_size.height, render_scale); let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0])); if options.include_background { @@ -94,19 +155,11 @@ fn render_slide_png( let mut ordered = slide.elements.iter().collect::>(); ordered.sort_by_key(|element| element.z_order()); for element in ordered { - render_element(&mut canvas, document, slide, element, options.scale)?; + render_element(&mut canvas, document, slide, element, render_scale)?; } - render_comment_overlays(&mut canvas, document, slide, options.scale); - - let mut output = std::io::Cursor::new(Vec::new()); - DynamicImage::ImageRgba8(canvas) - .write_to(&mut output, image::ImageFormat::Png) - .map_err(|error| PresentationArtifactError::RenderFailed { - action: "render_preview".to_string(), - message: error.to_string(), - })?; - Ok(output.into_inner()) + render_comment_overlays(&mut canvas, document, slide, render_scale); + Ok(canvas) } fn render_element( @@ -629,38 +682,41 @@ fn render_bar_chart( height: u32, scale: f32, ) { - let baseline = top + height; - draw_chart_axes(image, left, top, width, height); + let (min_value, max_value) = chart_value_domain(chart); + let baseline = chart_axis_y(top, height, min_value, max_value, 0.0); + draw_chart_axes(image, left, top, width, height, baseline); let series_count = chart.series.len().max(1) as u32; let category_count = chart.categories.len().max(1) as u32; - let max_value = chart - .series - .iter() - .flat_map(|series| series.values.iter().copied()) - .fold(0.0f64, f64::max) - .max(1.0); let group_width = width as f32 / category_count as f32; let bar_width = (group_width / series_count as f32 * 0.72).max(2.0); for (series_index, series) in chart.series.iter().enumerate() { let color = chart_series_color(series, series_index); for (value_index, value) in series.values.iter().enumerate() { - let bar_height = ((*value / max_value) as f32 * height as f32).round().max(0.0) as u32; let x = left as f32 + group_width * value_index as f32 + (group_width - bar_width * series_count as f32) / 2.0 + bar_width * series_index as f32; - let y = baseline.saturating_sub(bar_height); + let value_y = chart_axis_y(top, height, min_value, max_value, *value); + let y = value_y.min(baseline); + let bar_height = value_y.abs_diff(baseline).max(1); fill_rect( image, x.round() as u32, y, bar_width.round().max(1.0) as u32, - bar_height.max(1), + bar_height, color, ); } } - render_category_labels(image, &chart.categories, left, baseline + 4, width, scale); + render_category_labels( + image, + &chart.categories, + left, + top + height + 4, + width, + scale, + ); } fn render_line_chart( @@ -672,21 +728,16 @@ fn render_line_chart( height: u32, scale: f32, ) { - draw_chart_axes(image, left, top, width, height); - let baseline = top + height; + let (min_value, max_value) = chart_value_domain(chart); + let baseline = chart_axis_y(top, height, min_value, max_value, 0.0); + draw_chart_axes(image, left, top, width, height, baseline); let point_count = chart.categories.len().max(2); - let max_value = chart - .series - .iter() - .flat_map(|series| series.values.iter().copied()) - .fold(0.0f64, f64::max) - .max(1.0); for (series_index, series) in chart.series.iter().enumerate() { let color = chart_series_color(series, series_index); let mut points = Vec::new(); for (value_index, value) in series.values.iter().enumerate() { let x = left as f32 + width as f32 * value_index as f32 / (point_count - 1) as f32; - let y = baseline as f32 - ((*value / max_value) as f32 * height as f32); + let y = chart_axis_y(top, height, min_value, max_value, *value) as f32; points.push((x, y)); } if points.len() >= 2 @@ -717,7 +768,38 @@ fn render_line_chart( ); } } - render_category_labels(image, &chart.categories, left, baseline + 4, width, scale); + render_category_labels( + image, + &chart.categories, + left, + top + height + 4, + width, + scale, + ); +} + +fn chart_value_domain(chart: &ChartElement) -> (f64, f64) { + let mut min_value = 0.0f64; + let mut max_value = 0.0f64; + for value in chart + .series + .iter() + .flat_map(|series| series.values.iter().copied()) + { + min_value = min_value.min(value); + max_value = max_value.max(value); + } + if (max_value - min_value).abs() < f64::EPSILON { + max_value += 1.0; + } + (min_value, max_value) +} + +fn chart_axis_y(top: u32, height: u32, min_value: f64, max_value: f64, value: f64) -> u32 { + let span = (max_value - min_value).max(f64::EPSILON); + let normalized = ((value - min_value) / span).clamp(0.0, 1.0); + let bottom = top + height; + bottom.saturating_sub((normalized * height as f64).round() as u32) } fn render_pie_chart( @@ -776,9 +858,30 @@ fn render_chart_legend( if cursor >= left + width { break; } - fill_rect(image, cursor, top + (height.saturating_sub(swatch)) / 2, swatch, swatch, chart_series_color(series, index)); + fill_rect( + image, + cursor, + top + (height.saturating_sub(swatch)) / 2, + swatch, + swatch, + chart_series_color(series, index), + ); cursor += swatch + scaled_dimension(4, scale); - let label_width = scaled_dimension((series.name.chars().count() as u32).saturating_mul(7), scale).max(40); + let label_style = TextStyle { + font_size: Some(12), + color: Some(DEFAULT_TEXT_HEX.to_string()), + ..TextStyle::default() + }; + let label_rich_text = RichTextState { + layout: TextLayoutState { + vertical_alignment: Some(TextVerticalAlignment::Middle), + ..TextLayoutState::default() + }, + ..RichTextState::default() + }; + let label_width = measure_text_width(&series.name, &label_style, &label_rich_text, None, scale) + .saturating_add(scaled_dimension(8, scale)) + .max(40); draw_text_in_bounds( image, TextBounds { @@ -788,18 +891,8 @@ fn render_chart_legend( height, }, &series.name, - &TextStyle { - font_size: Some(12), - color: Some(DEFAULT_TEXT_HEX.to_string()), - ..TextStyle::default() - }, - &RichTextState { - layout: TextLayoutState { - vertical_alignment: Some(TextVerticalAlignment::Middle), - ..TextLayoutState::default() - }, - ..RichTextState::default() - }, + &label_style, + &label_rich_text, None, scale, ); @@ -807,6 +900,20 @@ fn render_chart_legend( } } +fn measure_text_width( + text: &str, + style: &TextStyle, + rich_text: &RichTextState, + hyperlink: Option<&HyperlinkState>, + scale: f32, +) -> u32 { + let font_px = scaled_dimension(style.font_size.unwrap_or(14), scale).max(MIN_TEXT_PIXELS); + styled_glyphs(text, style, rich_text, hyperlink) + .iter() + .map(|glyph| measure_glyph_width(glyph, font_px)) + .sum() +} + fn render_comment_overlays( canvas: &mut RgbaImage, document: &PresentationDocument, @@ -922,11 +1029,12 @@ fn draw_text_in_bounds( let mut font_px = scaled_dimension(style.font_size.unwrap_or(14), scale).max(MIN_TEXT_PIXELS); let auto_fit = rich_text.layout.auto_fit.unwrap_or(TextAutoFitMode::None); let config = loop { - let line_height = ((font_px as f32) * 1.35).round().max(font_px as f32) as u32; + let (line_height, baseline_offset) = preview_line_metrics(style, font_px); let config = TextRenderConfig { width: content_width, font_px, line_height, + baseline_offset, wrap, vertical_alignment, alignment, @@ -972,7 +1080,8 @@ fn draw_text_in_bounds( let mut x = start_x; for glyph in &line.glyphs { - let glyph_width = draw_bitmap_glyph(image, x, y, glyph, config.font_px); + let glyph_width = + draw_bitmap_glyph(image, x, y, glyph, config.font_px, config.baseline_offset); x = x.saturating_add(glyph_width); if glyph.ch == ' ' && justify_gap > 0 { x = x.saturating_add(justify_gap); @@ -1043,11 +1152,13 @@ fn layout_text_lines(glyphs: &[StyledGlyph], config: TextRenderConfig) -> Vec Vec config.width { - lines.push(TextLine { - glyphs: std::mem::take(&mut current), - width: current_width, - spaces: current_spaces, - }); + push_text_line( + &mut lines, + &mut current, + &mut current_width, + &mut current_spaces, + config.font_px, + ); current_width = 0; current_spaces = 0; + if matches!(glyph.ch, ' ' | '\t') { + continue; + } + } + if current.is_empty() && matches!(glyph.ch, ' ' | '\t') { + continue; } if glyph.ch == ' ' { current_spaces += 1; @@ -1072,21 +1191,63 @@ fn layout_text_lines(glyphs: &[StyledGlyph], config: TextRenderConfig) -> Vec, + current: &mut Vec, + current_width: &mut u32, + current_spaces: &mut usize, + font_px: u32, +) { + while let Some(glyph) = current.last() { + if !matches!(glyph.ch, ' ' | '\t') { + break; + } + let Some(trimmed) = current.pop() else { + break; + }; + *current_width = current_width.saturating_sub(measure_glyph_width(&trimmed, font_px)); + if trimmed.ch == ' ' { + *current_spaces = current_spaces.saturating_sub(1); + } + } + lines.push(TextLine { + glyphs: std::mem::take(current), + width: *current_width, + spaces: *current_spaces, + }); +} + fn draw_bitmap_glyph( image: &mut RgbaImage, left: u32, top: u32, glyph: &StyledGlyph, font_px: u32, + baseline_offset: u32, +) -> u32 { + if let Some(font) = preview_font_for_style(&glyph.style) { + return draw_outline_glyph(image, left, top, glyph, font_px, baseline_offset, &font); + } + draw_bitmap_glyph_fallback(image, left, top, glyph, font_px) +} + +fn draw_bitmap_glyph_fallback( + image: &mut RgbaImage, + left: u32, + top: u32, + glyph: &StyledGlyph, + font_px: u32, ) -> u32 { let scale = (font_px / 8).max(1); if glyph.ch == ' ' { @@ -1122,6 +1283,30 @@ fn draw_bitmap_glyph( } fn measure_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 { + if let Some(font) = preview_font_for_style(&glyph.style) { + return measure_outline_glyph_width(glyph, font_px, &font); + } + measure_bitmap_glyph_width(glyph, font_px) +} + +fn preview_line_metrics(style: &TextStyle, font_px: u32) -> (u32, u32) { + if let Some(font) = preview_font_for_style(style) + && let Some(metrics) = font.horizontal_line_metrics(font_px as f32) + { + return ( + metrics.new_line_size.ceil().max(font_px as f32).max(1.0) as u32, + metrics.ascent.ceil().max(1.0) as u32, + ); + } + ( + ((font_px as f32) * 1.35).round().max(font_px as f32) as u32, + ((font_px as f32) * DEFAULT_PREVIEW_FONT_SIZE_RATIO) + .round() + .max(1.0) as u32, + ) +} + +fn measure_bitmap_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 { let scale = (font_px / 8).max(1); match glyph.ch { ' ' => 4 * scale, @@ -1130,6 +1315,207 @@ fn measure_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 { } } +fn preview_font_for_style(style: &TextStyle) -> Option> { + let key = normalize_font_name(style.font_family.as_deref().unwrap_or("default")); + let cache = PREVIEW_FONT_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); + if let Ok(cache) = cache.lock() + && let Some(font) = cache.get(&key).cloned() + { + return font; + } + + let font = load_preview_font(style.font_family.as_deref()); + if let Ok(mut cache) = cache.lock() { + cache.insert(key, font.clone()); + } + font +} + +fn load_preview_font(requested_family: Option<&str>) -> Option> { + let mut candidates = requested_family + .into_iter() + .flat_map(preview_font_family_candidates) + .collect::>(); + candidates.extend(PREVIEW_FONT_FALLBACKS.iter().map(std::string::ToString::to_string)); + + for family in candidates { + if let Some(path) = find_system_font_path(&family) + && let Some(font) = load_preview_font_from_path(&path) + { + return Some(font); + } + } + None +} + +fn preview_font_family_candidates(family: &str) -> Vec { + let normalized = normalize_font_name(family); + let mut candidates = vec![family.to_string()]; + if normalized.contains("aptos") { + candidates.extend(["Arial", "Helvetica", "Avenir"].map(str::to_string)); + } else if normalized.contains("timesnewroman") { + candidates.push("Times".to_string()); + } else if normalized.contains("couriernew") { + candidates.push("Courier".to_string()); + } + candidates +} + +fn find_system_font_path(family: &str) -> Option { + let needle = normalize_font_name(family); + let mut partial_match = None; + for directory in PREVIEW_FONT_DIRS { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if !supported_preview_font_path(&path) { + continue; + } + let Some(stem) = path.file_stem() else { + continue; + }; + let stem = stem.to_string_lossy(); + let normalized_stem = normalize_font_name(&stem); + if normalized_stem == needle { + return Some(path); + } + if partial_match.is_none() && normalized_stem.contains(&needle) { + partial_match = Some(path); + } + } + } + partial_match +} + +fn supported_preview_font_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .map(|extension| matches!(extension.to_ascii_lowercase().as_str(), "ttf" | "otf")) + .unwrap_or(false) +} + +fn normalize_font_name(name: &str) -> String { + name.chars() + .filter(char::is_ascii_alphanumeric) + .flat_map(char::to_lowercase) + .collect() +} + +fn load_preview_font_from_path(path: &Path) -> Option> { + let bytes = std::fs::read(path).ok()?; + Font::from_bytes(bytes, fontdue::FontSettings::default()) + .ok() + .map(Arc::new) +} + +fn measure_outline_glyph_width(glyph: &StyledGlyph, font_px: u32, font: &Font) -> u32 { + let font_px = font_px.max(MIN_TEXT_PIXELS) as f32; + if glyph.ch == '\t' { + let space_metrics = font.metrics(' ', font_px); + return (space_metrics.advance_width.max(font_px / 3.0) * 4.0) + .ceil() + .max(1.0) as u32; + } + let metrics = font.metrics(glyph.ch, font_px); + metrics + .advance_width + .max(metrics.width as f32 + metrics.xmin.max(0) as f32) + .max(font_px / 3.0) + .ceil() + .max(1.0) as u32 +} + +fn draw_outline_glyph( + image: &mut RgbaImage, + left: u32, + top: u32, + glyph: &StyledGlyph, + font_px: u32, + baseline_offset: u32, + font: &Font, +) -> u32 { + let font_px = font_px.max(MIN_TEXT_PIXELS); + let width = measure_outline_glyph_width(glyph, font_px, font); + if glyph.ch == ' ' || glyph.ch == '\t' { + return width; + } + + let (metrics, bitmap) = font.rasterize(glyph.ch, font_px as f32); + let baseline = top as i32 + baseline_offset as i32; + let color = glyph_color(&glyph.style, glyph.is_link); + blit_font_bitmap(image, left, baseline, glyph, &metrics, &bitmap, color); + if glyph.style.bold { + blit_font_bitmap( + image, + left.saturating_add(1), + baseline, + glyph, + &metrics, + &bitmap, + color, + ); + } + if glyph.style.underline || glyph.is_link { + let underline_y = top.saturating_add(font_px).saturating_sub(font_px / 8 + 1); + fill_rect(image, left, underline_y, width.max(1), (font_px / 12).max(1), color); + } + width +} + +fn blit_font_bitmap( + image: &mut RgbaImage, + left: u32, + baseline: i32, + glyph: &StyledGlyph, + metrics: &fontdue::Metrics, + bitmap: &[u8], + color: Rgba, +) { + if metrics.width == 0 || metrics.height == 0 { + return; + } + let origin_x = left as i32 + metrics.xmin; + let origin_y = baseline - metrics.height as i32 - metrics.ymin; + let italic_skew = if glyph.style.italic { + (metrics.height as f32 * 0.18).round().max(1.0) as i32 + } else { + 0 + }; + + for row in 0..metrics.height { + let row_skew = if italic_skew > 0 { + (((metrics.height - row) as f32 / metrics.height as f32) * italic_skew as f32).round() + as i32 + } else { + 0 + }; + for column in 0..metrics.width { + let alpha = bitmap[row * metrics.width + column]; + if alpha == 0 { + continue; + } + let dest_x = origin_x + column as i32 + row_skew; + let dest_y = origin_y + row as i32; + if dest_x < 0 + || dest_y < 0 + || dest_x >= image.width() as i32 + || dest_y >= image.height() as i32 + { + continue; + } + let source = Rgba([ + color.0[0], + color.0[1], + color.0[2], + ((u16::from(alpha) * u16::from(color.0[3])) / 255) as u8, + ]); + blend_pixel(image.get_pixel_mut(dest_x as u32, dest_y as u32), source); + } + } +} + fn glyph_color(style: &TextStyle, is_link: bool) -> Rgba { if let Some(color) = &style.color { return parse_rgba(color, 255); @@ -1326,9 +1712,10 @@ fn shape_path( let width = width.max(1) as f32; let height = height.max(1) as f32; match geometry { - ShapeGeometry::Rectangle | ShapeGeometry::RoundedRectangle => { + ShapeGeometry::Rectangle => { polygon_path(&[(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]) } + ShapeGeometry::RoundedRectangle => rounded_rectangle_path(width, height), ShapeGeometry::Ellipse => ellipse_path(width as u32, height as u32), ShapeGeometry::Triangle => polygon_path(&[(width / 2.0, 0.0), (width, height), (0.0, height)]), ShapeGeometry::RightTriangle => polygon_path(&[(0.0, 0.0), (width, height), (0.0, height)]), @@ -1480,6 +1867,55 @@ fn ellipse_path(width: u32, height: u32) -> Result Result { + let radius = (width.min(height) * 0.18).max(2.0); + let radius = radius.min(width / 2.0).min(height / 2.0); + let control = radius * (4.0 * (2.0_f32.sqrt() - 1.0) / 3.0); + let mut builder = PathBuilder::new(); + builder.move_to(radius, 0.0); + builder.line_to(width - radius, 0.0); + builder.cubic_to( + width - radius + control, + 0.0, + width, + radius - control, + width, + radius, + ); + builder.line_to(width, height - radius); + builder.cubic_to( + width, + height - radius + control, + width - radius + control, + height, + width - radius, + height, + ); + builder.line_to(radius, height); + builder.cubic_to( + radius - control, + height, + 0.0, + height - radius + control, + 0.0, + height - radius, + ); + builder.line_to(0.0, radius); + builder.cubic_to( + 0.0, + radius - control, + radius - control, + 0.0, + radius, + 0.0, + ); + builder.close(); + builder.finish().ok_or_else(|| PresentationArtifactError::RenderFailed { + action: "render_preview".to_string(), + message: "failed to build rounded rectangle path".to_string(), + }) +} + fn regular_polygon_path( sides: usize, width: f32, @@ -1743,10 +2179,17 @@ fn draw_vertical_line(image: &mut RgbaImage, x: u32, color: Rgba, thickness: fill_rect(image, x.saturating_sub(thickness / 2), 0, thickness.max(1), image.height(), color); } -fn draw_chart_axes(image: &mut RgbaImage, left: u32, top: u32, width: u32, height: u32) { +fn draw_chart_axes( + image: &mut RgbaImage, + left: u32, + top: u32, + width: u32, + plot_height: u32, + baseline: u32, +) { let axis_color = parse_rgba(DEFAULT_TABLE_GRID_HEX, 255); - fill_rect(image, left, top, 1, height, axis_color); - fill_rect(image, left, top + height, width, 1, axis_color); + fill_rect(image, left, top, 1, plot_height, axis_color); + fill_rect(image, left, baseline, width, 1, axis_color); } fn render_category_labels( @@ -1852,3 +2295,79 @@ fn resolve_render_slide_index( } Ok(0) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn wrapped_lines_skip_leading_spaces() { + let text = "One strong visual lands the story."; + let style = TextStyle::default(); + let glyphs = styled_glyphs(text, &style, &RichTextState::default(), None); + let prefix = "One strong visual lands the"; + let width = glyphs + .iter() + .take(prefix.chars().count()) + .map(|glyph| measure_glyph_width(glyph, 14)) + .sum(); + let lines = layout_text_lines( + &glyphs, + TextRenderConfig { + width, + font_px: 14, + line_height: 18, + baseline_offset: 12, + wrap: TextWrapMode::Square, + vertical_alignment: TextVerticalAlignment::Top, + alignment: TextAlignment::Left, + }, + ); + + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0].glyphs.iter().map(|glyph| glyph.ch).collect::(), + prefix + ); + assert_eq!( + lines[1].glyphs.iter().map(|glyph| glyph.ch).collect::(), + "story." + ); + } + + #[test] + fn wrapped_lines_trim_trailing_spaces() { + let text = "One strong visual lands the story."; + let style = TextStyle::default(); + let glyphs = styled_glyphs(text, &style, &RichTextState::default(), None); + let prefix = "One strong visual lands the "; + let width = glyphs + .iter() + .take(prefix.chars().count()) + .map(|glyph| measure_glyph_width(glyph, 14)) + .sum(); + let lines = layout_text_lines( + &glyphs, + TextRenderConfig { + width, + font_px: 14, + line_height: 18, + baseline_offset: 12, + wrap: TextWrapMode::Square, + vertical_alignment: TextVerticalAlignment::Top, + alignment: TextAlignment::Left, + }, + ); + + assert_eq!(lines.len(), 2); + assert_eq!( + lines[0].glyphs.iter().map(|glyph| glyph.ch).collect::(), + "One strong visual lands the" + ); + assert_eq!( + lines[1].glyphs.iter().map(|glyph| glyph.ch).collect::(), + "story." + ); + } +} diff --git a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs index 8606649c38..eb85685078 100644 --- a/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs +++ b/codex-rs/artifact-presentation/src/presentation_artifact/snapshot.rs @@ -69,6 +69,13 @@ fn layout_list(document: &PresentationDocument) -> Vec { .collect() } +fn master_layout_list(document: &PresentationDocument) -> Vec { + layout_list(document) + .into_iter() + .filter(|layout| layout.kind == "master") + .collect() +} + fn points_to_emu(points: u32) -> u32 { points.saturating_mul(POINT_TO_EMU) } @@ -223,54 +230,12 @@ fn load_image_payload_from_uri( uri: &str, action: &str, ) -> Result { - let response = - reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to fetch image `{uri}`: {error}"), - })?; - let status = response.status(); - if !status.is_success() { - return Err(PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to fetch image `{uri}`: HTTP {status}"), - }); - } - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.split(';').next().unwrap_or(value).trim().to_string()); - let bytes = response - .bytes() - .map_err(|error| PresentationArtifactError::InvalidArgs { - action: action.to_string(), - message: format!("failed to read image `{uri}`: {error}"), - })?; - build_image_payload( - bytes.to_vec(), - infer_remote_image_filename(uri, content_type.as_deref()), - action, - ) -} - -fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String { - let path_name = reqwest::Url::parse(uri) - .ok() - .and_then(|url| { - url.path_segments() - .and_then(Iterator::last) - .map(str::to_owned) - }) - .filter(|segment| !segment.is_empty()); - match (path_name, content_type) { - (Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name, - (Some(path_name), Some(content_type)) => { - format!("{path_name}.{}", image_extension_from_mime(content_type)) - } - (Some(path_name), None) => path_name, - (None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)), - (None, None) => "image.png".to_string(), - } + Err(PresentationArtifactError::UnsupportedFeature { + action: action.to_string(), + message: format!( + "remote image URIs are not supported for `{action}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)" + ), + }) } fn build_image_payload( diff --git a/codex-rs/artifact-presentation/src/tests.rs b/codex-rs/artifact-presentation/src/tests.rs index 9cd00e19b6..4646ce5c5b 100644 --- a/codex-rs/artifact-presentation/src/tests.rs +++ b/codex-rs/artifact-presentation/src/tests.rs @@ -3,6 +3,7 @@ use base64::Engine; use image::GenericImageView; use pretty_assertions::assert_eq; use std::io::Read; +use std::io::Write; fn zip_entry_text( path: &std::path::Path, @@ -22,6 +23,63 @@ fn zip_entry_names(path: &std::path::Path) -> Result, Box Result<(), Box> { + let input = std::fs::File::open(source)?; + let mut archive = zip::ZipArchive::new(input)?; + let output = std::fs::File::create(target)?; + let mut writer = zip::ZipWriter::new(output); + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let name = entry.name().to_string(); + if name == "ppt/codex-document.json" { + continue; + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + if name == "ppt/presentation.xml" { + let xml = String::from_utf8(bytes)?; + bytes = replace_slide_size_in_presentation_xml(&xml, slide_width_emu, slide_height_emu) + .into_bytes(); + } + writer.start_file(name, zip::write::SimpleFileOptions::default())?; + writer.write_all(&bytes)?; + } + writer.finish()?; + Ok(()) +} + +fn replace_slide_size_in_presentation_xml(xml: &str, width_emu: u32, height_emu: u32) -> String { + let Some(tag_start) = xml.find("") else { + return xml.to_string(); + }; + let tag_end = tag_start + tag_end_offset; + let tag = &xml[tag_start..tag_end]; + let tag = replace_xml_attribute(tag, "cx", &width_emu.to_string()); + let tag = replace_xml_attribute(&tag, "cy", &height_emu.to_string()); + format!("{}{tag}/>{}", &xml[..tag_start], &xml[tag_end + 2..]) +} + +fn replace_xml_attribute(tag: &str, attribute: &str, value: &str) -> String { + let needle = format!(r#"{attribute}=""#); + let Some(start) = tag.find(&needle) else { + return tag.to_string(); + }; + let value_start = start + needle.len(); + let Some(value_end_offset) = tag[value_start..].find('"') else { + return tag.to_string(); + }; + let value_end = value_start + value_end_offset; + format!("{}{}{}", &tag[..value_start], value, &tag[value_end..]) +} + fn parse_ndjson_lines(ndjson: &str) -> Result, serde_json::Error> { ndjson .lines() @@ -52,6 +110,27 @@ fn rect_contains_pixel( false } +fn rect_contains_pixel_where( + image: &image::DynamicImage, + left: u32, + top: u32, + width: u32, + height: u32, + predicate: F, +) -> bool +where + F: Fn([u8; 4]) -> bool, +{ + for y in top..top.saturating_add(height) { + for x in left..left.saturating_add(width) { + if predicate(image.get_pixel(x, y).0) { + return true; + } + } + } + false +} + #[test] fn manager_can_create_add_text_and_export() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -281,6 +360,450 @@ fn exported_images_are_real_pictures_with_media_parts() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Slide Master Styles" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("slide-master-styles.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let slide_master_xml = zip_entry_text( + &temp_dir.path().join("slide-master-styles.pptx"), + "ppt/slideMasters/slideMaster1.xml", + )?; + assert!(slide_master_xml.contains("")); + assert!(slide_master_xml.contains("")); + assert!(slide_master_xml.contains("")); + assert!(slide_master_xml.contains("")); + Ok(()) +} + +#[test] +fn exported_charts_are_real_pictures_with_media_parts() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Chart Export" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "chart_type": "bar", + "position": { "left": 48, "top": 64, "width": 320, "height": 220 }, + "title": "Weekly Active Teams", + "categories": ["Week 1", "Week 2", "Week 3", "Week 4"], + "series": [ + { "name": "Actual", "values": [12, 18, 24, 31], "fill": "#2B5797" }, + { "name": "Target", "values": [15, 20, 26, 30], "fill": "#D97A00" } + ] + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("charts-as-images.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let pptx_path = temp_dir.path().join("charts-as-images.pptx"); + let slide_xml = zip_entry_text(&pptx_path, "ppt/slides/slide1.xml")?; + let rels_xml = zip_entry_text(&pptx_path, "ppt/slides/_rels/slide1.xml.rels")?; + let content_types_xml = zip_entry_text(&pptx_path, "[Content_Types].xml")?; + let entry_names = zip_entry_names(&pptx_path)?; + + assert!(slide_xml.contains("")); + assert!(slide_xml.contains(r#"descr="Weekly Active Teams""#)); + assert!(slide_xml.contains(r#"r:embed="rIdImage1""#)); + assert!(!slide_xml.contains(" Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Theme Export", + "theme": { + "color_scheme": { + "accent1": "#123456", + "bg1": "#FAF7F0", + "tx1": "#222222" + }, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("theme-export.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let theme_xml = zip_entry_text( + &temp_dir.path().join("theme-export.pptx"), + "ppt/theme/theme1.xml", + )?; + assert!(theme_xml.contains(r#""#)); + assert!(theme_xml.contains(r#"typeface="Aptos Display""#)); + assert!(theme_xml.contains(r#"typeface="Aptos""#)); + assert!(theme_xml.contains(r#""#)); + assert!(theme_xml.contains(r#""#)); + assert!(theme_xml.contains(r#""#)); + Ok(()) +} + +#[test] +fn exported_text_shapes_preserve_text_styling() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Styled Text Export", + "theme": { + "color_scheme": {}, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id.clone(); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added_text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Alpha Beta", + "position": { "left": 40, "top": 40, "width": 220, "height": 80 }, + "font_size": 24, + "font_family": "Aptos", + "color": "#112233", + "alignment": "right", + "bold": true, + "text_layout": { + "vertical_alignment": "bottom", + "auto_fit": "shrinkText" + } + }), + }, + temp_dir.path(), + )?; + let text_id = added_text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": text_id, + "query": "Beta", + "link": { + "uri": "https://example.com/docs", + "is_external": true + }, + "spacing_before": 12, + "spacing_after": 6, + "line_spacing": 1.5, + "styling": { + "italic": true, + "underline": true, + "color": "#445566" + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 300, "top": 60, "width": 180, "height": 100 }, + "fill": "#2F6B5F", + "text": "Status", + "text_style": { + "font_family": "Aptos Display", + "font_size": 20, + "color": "#FFFFFF", + "alignment": "center", + "bold": true + }, + "text_layout": { + "vertical_alignment": "middle" + } + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("styled-text-export.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let slide_xml = zip_entry_text( + &temp_dir.path().join("styled-text-export.pptx"), + "ppt/slides/slide1.xml", + )?; + let rels_xml = zip_entry_text( + &temp_dir.path().join("styled-text-export.pptx"), + "ppt/slides/_rels/slide1.xml.rels", + )?; + assert!(slide_xml.contains(r#"anchor="b""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#"sz="2400""#)); + assert!(slide_xml.contains(r#"b="1""#)); + assert!(slide_xml.contains(r#"typeface="Aptos""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(">Beta<")); + assert!(slide_xml.contains(r#"i="1""#)); + assert!(slide_xml.contains(r#"u="sng""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#"anchor="ctr""#)); + assert!(slide_xml.contains(r#""#)); + assert!(rels_xml.contains("relationships/hyperlink")); + assert!(rels_xml.contains(r#"Target="https://example.com/docs""#)); + Ok(()) +} + +#[test] +fn exported_table_cells_preserve_rich_text_styling() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Styled Table Export", + "theme": { + "color_scheme": {}, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id.clone(); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added_table = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 40, "top": 40, "width": 240, "height": 90 }, + "rows": [["Alpha Beta"]] + }), + }, + temp_dir.path(), + )?; + let table_id = added_table + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("table id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_table_cell".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 0, + "column": 0, + "value": "Alpha Beta", + "alignment": "right", + "styling": { + "font_family": "Aptos", + "font_size": 20, + "color": "#112233", + "bold": true + } + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "row": 0, + "column": 0, + "query": "Beta", + "link": { + "uri": "https://example.com/table", + "is_external": true + }, + "spacing_before": 8, + "spacing_after": 4, + "line_spacing": 1.25, + "styling": { + "italic": true, + "underline": true, + "color": "#445566" + } + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("styled-table-export.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let slide_xml = zip_entry_text( + &temp_dir.path().join("styled-table-export.pptx"), + "ppt/slides/slide1.xml", + )?; + let rels_xml = zip_entry_text( + &temp_dir.path().join("styled-table-export.pptx"), + "ppt/slides/_rels/slide1.xml.rels", + )?; + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#"sz="2000""#)); + assert!(slide_xml.contains(r#"b="1""#)); + assert!(slide_xml.contains(r#"typeface="Aptos""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(">Beta<")); + assert!(slide_xml.contains(r#"i="1""#)); + assert!(slide_xml.contains(r#"u="sng""#)); + assert!(slide_xml.contains(r#""#)); + assert!(slide_xml.contains(r#" Result<(), Box> { let request: PresentationArtifactToolRequest = serde_json::from_value(serde_json::json!({ @@ -578,29 +1101,10 @@ fn preview_image_writer_supports_jpeg_scale_and_svg() -> Result<(), Box Result<(), Box> { - let mut image_bytes = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( - 16, - 8, - image::Rgba([0x11, 0x88, 0xCC, 0xFF]), - )) - .write_to(&mut image_bytes, image::ImageFormat::Png)?; - let png = image_bytes.into_inner(); - - let server = tiny_http::Server::http("127.0.0.1:0").expect("server"); - let port = server.server_addr().to_ip().expect("ip addr").port(); - let server_thread = std::thread::spawn(move || { - for request in server.incoming_requests().take(2) { - let response = tiny_http::Response::from_data(png.clone()).with_header( - tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"image/png"[..]) - .expect("header"), - ); - request.respond(response).expect("respond"); - } - }); - +fn image_uris_are_rejected_for_add_and_replace_images() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; + let image_path = temp_dir.path().join("local.png"); + image::RgbaImage::from_pixel(16, 8, image::Rgba([0x11, 0x88, 0xCC, 0xFF])).save(&image_path)?; let mut manager = PresentationArtifactManager::default(); let created = manager.execute( PresentationArtifactRequest { @@ -619,14 +1123,32 @@ fn image_uris_can_add_and_replace_images() -> Result<(), Box Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let original_path = temp_dir.path().join("original.png"); + let replacement_path = temp_dir.path().join("replacement.png"); + image::RgbaImage::from_pixel(24, 12, image::Rgba([0x11, 0x88, 0xCC, 0xFF])) + .save(&original_path)?; + image::RgbaImage::from_pixel(32, 16, image::Rgba([0xD0, 0x44, 0x55, 0xFF])) + .save(&replacement_path)?; + let replacement_blob = + base64::engine::general_purpose::STANDARD.encode(std::fs::read(&replacement_path)?); + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Replace image state" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": original_path, + "position": { "left": 24, "top": 24, "width": 200, "height": 120 }, + "fit": "contain", + "alt": "Existing alt text" + }), + }, + temp_dir.path(), + )?; + let image_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("image id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("im/{image_id}"), + "fit": "cover", + "crop": { "left": 0.15, "top": 0.05, "right": 0.10, "bottom": 0.0 }, + "lock_aspect_ratio": true + }), + }, + temp_dir.path(), + )?; manager.execute( PresentationArtifactRequest { artifact_id: Some(artifact_id.clone()), action: "replace_image".to_string(), args: serde_json::json!({ - "element_id": format!("im/{element_id}"), - "uri": format!("http://127.0.0.1:{port}/updated.png"), - "fit": "contain" + "element_id": format!("im/{image_id}"), + "blob": replacement_blob }), }, temp_dir.path(), )?; - let inspect = manager.execute( + let resolved = manager.execute( PresentationArtifactRequest { artifact_id: Some(artifact_id), - action: "inspect".to_string(), - args: serde_json::json!({ "kind": "image" }), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("im/{image_id}") }), }, temp_dir.path(), )?; - assert!( - inspect - .inspect_ndjson - .expect("image inspect") - .contains("\"fit\":\"Contain\"") + let record = resolved.resolved_record.expect("resolved image"); + assert_eq!(record["fit"], "Cover"); + assert_eq!(record["alt"], "Existing alt text"); + assert_eq!(record["lockAspectRatio"], true); + assert_eq!(record["isPlaceholder"], false); + assert_eq!( + record + .get("crop") + .and_then(serde_json::Value::as_object) + .and_then(|crop| crop.get("left")) + .and_then(serde_json::Value::as_f64), + Some(0.15) ); - server_thread.join().expect("server thread"); Ok(()) } @@ -1147,6 +1758,141 @@ fn hyperlinks_are_inspectable_and_exported() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Slide hyperlinks" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + for slide_index in 0..3 { + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "notes": format!("slide {slide_index}") }), + }, + temp_dir.path(), + )?; + } + + let shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 24, "top": 24, "width": 180, "height": 60 }, + "text": "Go" + }), + }, + temp_dir.path(), + )?; + let shape_id = shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("shape id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "set_hyperlink".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "link_type": "slide", + "slide_index": 2 + }), + }, + temp_dir.path(), + )?; + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "insert_slide".to_string(), + args: serde_json::json!({ "index": 0 }), + }, + temp_dir.path(), + )?; + let after_insert = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + after_insert + .resolved_record + .as_ref() + .and_then(|record| record.get("hyperlink")) + .and_then(|hyperlink| hyperlink.get("slideIndex")) + .and_then(serde_json::Value::as_u64), + Some(3) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "move_slide".to_string(), + args: serde_json::json!({ "from_index": 3, "to_index": 1 }), + }, + temp_dir.path(), + )?; + let after_move = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + after_move + .resolved_record + .as_ref() + .and_then(|record| record.get("hyperlink")) + .and_then(|hyperlink| hyperlink.get("slideIndex")) + .and_then(serde_json::Value::as_u64), + Some(1) + ); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "delete_slide".to_string(), + args: serde_json::json!({ "slide_index": 1 }), + }, + temp_dir.path(), + )?; + let after_delete = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{shape_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + after_delete + .resolved_record + .as_ref() + .and_then(|record| record.get("hyperlink")), + None + ); + Ok(()) +} + #[test] fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -1225,6 +1971,23 @@ fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Custom size", + "slide_size": { "width": 960, "height": 540 } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + + let codex_path = temp_dir.path().join("codex-sized.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": codex_path }), + }, + temp_dir.path(), + )?; + + let imported_source = temp_dir.path().join("non-codex-sized.pptx"); + rewrite_pptx_without_codex_metadata( + &temp_dir.path().join("codex-sized.pptx"), + &imported_source, + 12_192_000, + 6_858_000, + )?; + + let imported = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "import_pptx".to_string(), + args: serde_json::json!({ "path": imported_source }), + }, + temp_dir.path(), + )?; + let roundtrip_path = temp_dir.path().join("roundtrip-sized.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(imported.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": roundtrip_path }), + }, + temp_dir.path(), + )?; + let presentation_xml = zip_entry_text( + &temp_dir.path().join("roundtrip-sized.pptx"), + "ppt/presentation.xml", + )?; + assert!(presentation_xml.contains(r#" Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ + "name": "Styled Notes Export", + "theme": { + "color_scheme": { + "tx1": "#222222" + }, + "major_font": "Aptos Display", + "minor_font": "Aptos" + } + }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id.clone(); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ "notes": "Alpha Beta" }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "format_text_range".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "notes": true, + "query": "Beta", + "link": { + "uri": "https://example.com/notes", + "is_external": true + }, + "spacing_before": 10, + "spacing_after": 5, + "line_spacing": 1.4, + "styling": { + "italic": true, + "underline": true, + "color": "#445566" + } + }), + }, + temp_dir.path(), + )?; + + let export_path = temp_dir.path().join("styled-notes-export.pptx"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(created.artifact_id), + action: "export_pptx".to_string(), + args: serde_json::json!({ "path": export_path }), + }, + temp_dir.path(), + )?; + + let notes_xml = zip_entry_text( + &temp_dir.path().join("styled-notes-export.pptx"), + "ppt/notesSlides/notesSlide1.xml", + )?; + let rels_xml = zip_entry_text( + &temp_dir.path().join("styled-notes-export.pptx"), + "ppt/notesSlides/_rels/notesSlide1.xml.rels", + )?; + assert!(notes_xml.contains(r#"typeface="Aptos""#)); + assert!(notes_xml.contains(r#"sz="1200""#)); + assert!(notes_xml.contains(r#""#)); + assert!(notes_xml.contains(">Beta<")); + assert!(notes_xml.contains(r#"i="1""#)); + assert!(notes_xml.contains(r#"u="sng""#)); + assert!(notes_xml.contains(r#""#)); + assert!(notes_xml.contains(r#""#)); + assert!(notes_xml.contains(r#""#)); + assert!(notes_xml.contains(r#""#)); + assert!(rels_xml.contains("relationships/hyperlink")); + assert!(rels_xml.contains(r#"Target="https://example.com/notes""#)); + Ok(()) +} + #[test] fn image_placeholders_and_anchor_updates_work() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -2458,6 +3378,378 @@ fn connectors_support_arrows_and_inspect() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Atomic text style" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Atomic", + "position": { "left": 40, "top": 40, "width": 180, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let text_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + let error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{text_id}"), + "fill": "#00AA00", + "stroke": { "color": "#222222", "width": 2 } + }), + }, + temp_dir.path(), + ) + .expect_err("unsupported stroke should reject the request"); + assert!(matches!( + error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + let resolved = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "resolve".to_string(), + args: serde_json::json!({ "id": format!("sh/{text_id}") }), + }, + temp_dir.path(), + )?; + assert_eq!( + resolved + .resolved_record + .as_ref() + .and_then(|record| record.get("fill")) + .cloned(), + None + ); + Ok(()) +} + +#[test] +fn update_shape_style_rejects_silently_ignored_text_shape_and_image_fields() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Unsupported style args" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let text = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_text_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "text": "Unsupported", + "position": { "left": 24, "top": 24, "width": 160, "height": 60 } + }), + }, + temp_dir.path(), + )?; + let text_id = text + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("text id"); + let added_shape = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 24, "top": 120, "width": 160, "height": 80 }, + "fill": "#cccccc" + }), + }, + temp_dir.path(), + )?; + let shape_id = added_shape + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(1)) + .cloned() + .expect("shape id"); + let added_image = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 220, "top": 24, "width": 120, "height": 80 }, + "prompt": "Placeholder image" + }), + }, + temp_dir.path(), + )?; + let image_id = added_image + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("image id"); + + let text_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{text_id}"), + "position": { "flip_horizontal": true } + }), + }, + temp_dir.path(), + ) + .expect_err("text nested transforms should be rejected"); + assert!(matches!( + text_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + + let shape_fit_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "fit": "contain" + }), + }, + temp_dir.path(), + ) + .expect_err("shape fit should be rejected"); + assert!(matches!( + shape_fit_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + + let shape_layout_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("sh/{shape_id}"), + "text_layout": { "wrap": "none" } + }), + }, + temp_dir.path(), + ) + .expect_err("shape text_layout without text should be rejected"); + assert!(matches!( + shape_layout_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + + let image_layout_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("im/{image_id}"), + "text_layout": { "wrap": "none" } + }), + }, + temp_dir.path(), + ) + .expect_err("image text_layout should be rejected"); + assert!(matches!( + image_layout_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + Ok(()) +} + +#[test] +fn update_shape_style_rejects_silently_ignored_connector_table_and_chart_fields() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Unsupported style args 2" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let connector = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_connector".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "connector_type": "straight", + "start": { "left": 20, "top": 20 }, + "end": { "left": 180, "top": 160 } + }), + }, + temp_dir.path(), + )?; + let connector_id = connector + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("connector id"); + let table = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 220, "top": 20, "width": 180, "height": 100 }, + "rows": [["A", "B"], ["C", "D"]] + }), + }, + temp_dir.path(), + )?; + let table_id = table + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.get(1)) + .cloned() + .expect("table id"); + let chart = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "chart_type": "bar", + "position": { "left": 24, "top": 220, "width": 220, "height": 140 }, + "categories": ["A", "B"], + "series": [{ "name": "Actual", "values": [1, 2] }] + }), + }, + temp_dir.path(), + )?; + let chart_id = chart + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.last()) + .cloned() + .expect("chart id"); + + let connector_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("cn/{connector_id}"), + "position": { "rotation": 10 } + }), + }, + temp_dir.path(), + ) + .expect_err("connector nested transforms should be rejected"); + assert!(matches!( + connector_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + + let table_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("tb/{table_id}"), + "position": { "flip_vertical": true } + }), + }, + temp_dir.path(), + ) + .expect_err("table nested transforms should be rejected"); + assert!(matches!( + table_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + + let chart_error = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("ch/{chart_id}"), + "text_layout": { "wrap": "none" } + }), + }, + temp_dir.path(), + ) + .expect_err("chart text_layout should be rejected"); + assert!(matches!( + chart_error, + PresentationArtifactError::UnsupportedFeature { .. } + )); + Ok(()) +} + #[test] fn shapes_support_stroke_dash_styles() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; @@ -2854,6 +4146,109 @@ fn manager_supports_table_cell_updates_and_merges() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Merge validation" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_table".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "position": { "left": 32, "top": 48, "width": 240, "height": 120 }, + "rows": [ + ["A1", "B1"], + ["A2", "B2"] + ] + }), + }, + temp_dir.path(), + )?; + let table_id = added + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .map(|id| format!("tb/{id}")) + .expect("table id"); + + let reversed = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "merge_table_cells".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "start_row": 1, + "end_row": 0, + "start_column": 0, + "end_column": 1 + }), + }, + temp_dir.path(), + ) + .expect_err("reversed merge should be rejected"); + assert!(matches!( + reversed, + PresentationArtifactError::InvalidArgs { .. } + )); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "merge_table_cells".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "start_row": 0, + "end_row": 0, + "start_column": 0, + "end_column": 1 + }), + }, + temp_dir.path(), + )?; + + let overlapping = manager + .execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "merge_table_cells".to_string(), + args: serde_json::json!({ + "element_id": table_id, + "start_row": 0, + "end_row": 1, + "start_column": 1, + "end_column": 1 + }), + }, + temp_dir.path(), + ) + .expect_err("overlapping merge should be rejected"); + assert!(matches!( + overlapping, + PresentationArtifactError::InvalidArgs { .. } + )); + Ok(()) +} + #[test] fn rich_text_comments_tables_and_charts_roundtrip_through_metadata() -> Result<(), Box> { @@ -2997,6 +4392,41 @@ fn rich_text_comments_tables_and_charts_roundtrip_through_metadata() }, temp_dir.path(), )?; + let comment_threads = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "list_comment_threads".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let comment_threads = comment_threads + .resolved_record + .expect("comment thread collection"); + assert_eq!( + comment_threads["commentSelf"]["displayName"], + serde_json::json!("Jamie Fox") + ); + assert_eq!( + comment_threads["commentThreads"].as_array().map(Vec::len), + Some(1) + ); + assert_eq!( + comment_threads["commentThreads"][0]["threadId"], + serde_json::json!("thread_1") + ); + let comment_thread = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "get_comment_thread".to_string(), + args: serde_json::json!({ "thread_id": "thread_1" }), + }, + temp_dir.path(), + )?; + let comment_thread = comment_thread.resolved_record.expect("comment thread"); + assert_eq!(comment_thread["anchor"], serde_json::json!("th/thread_1")); + assert_eq!(comment_thread["status"], serde_json::json!("active")); + assert_eq!(comment_thread["messages"].as_array().map(Vec::len), Some(2)); let table_added = manager.execute( PresentationArtifactRequest { @@ -3689,7 +5119,10 @@ fn render_preview_renders_image_and_table_content() -> Result<(), Box Result<(), Box Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Negative charts" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + for _ in 0..2 { + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + } + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "chart_type": "bar", + "position": { "left": 120, "top": 80, "width": 360, "height": 220 }, + "categories": ["Loss", "Gain"], + "series": [ + { "name": "Actual", "values": [-4, 6], "fill": "#4E79A7" } + ] + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_chart".to_string(), + args: serde_json::json!({ + "slide_index": 1, + "chart_type": "line", + "position": { "left": 120, "top": 80, "width": 360, "height": 220 }, + "categories": ["Loss", "Gain"], + "series": [ + { "name": "Actual", "values": [-4, 6], "fill": "#4E79A7" } + ] + }), + }, + temp_dir.path(), + )?; + + let bar_preview = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "render_preview".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + let bar_image = load_preview(&bar_preview.rendered_preview.expect("bar preview").png_bytes)?; + assert!( + rect_contains_pixel(&bar_image, 150, 210, 90, 80, [0x4E, 0x79, 0xA7, 0xFF]), + "expected negative bar fill in the lower plot half" + ); + assert!( + rect_contains_pixel(&bar_image, 280, 110, 90, 80, [0x4E, 0x79, 0xA7, 0xFF]), + "expected positive bar fill in the upper plot half" + ); + + let line_preview = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_preview".to_string(), + args: serde_json::json!({ "slide_index": 1 }), + }, + temp_dir.path(), + )?; + let line_image = load_preview( + &line_preview + .rendered_preview + .expect("line preview") + .png_bytes, + )?; + let is_series_pixel = |pixel: [u8; 4]| { + pixel[3] == 0xFF + && u16::from(pixel[2]) > u16::from(pixel[0]) + 20 + && u16::from(pixel[2]) > u16::from(pixel[1]) + 20 + }; + assert!( + rect_contains_pixel_where(&line_image, 0, 220, 360, 140, is_series_pixel), + "expected negative line point inside the plot area" + ); + assert!( + rect_contains_pixel_where(&line_image, 240, 0, 480, 220, is_series_pixel), + "expected positive line point inside the plot area" + ); + Ok(()) +} + #[test] fn export_preview_uses_native_renderer_for_single_slide() -> Result<(), Box> { @@ -3817,6 +5355,183 @@ fn export_preview_uses_native_renderer_for_single_slide() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let image_path = temp_dir.path().join("stacking-source.png"); + image::RgbaImage::from_pixel(40, 40, image::Rgba([0x2A, 0x80, 0xD7, 0xFF])) + .save(&image_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Preview stacking parity" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({ + "background_fill": "#D9EAF7" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": image_path, + "position": { "left": 160, "top": 80, "width": 180, "height": 180 }, + "fit": "stretch" + }), + }, + temp_dir.path(), + )?; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rounded_rectangle", + "position": { "left": 120, "top": 200, "width": 220, "height": 120 }, + "fill": "#FFFFFF" + }), + }, + temp_dir.path(), + )?; + + let target_path = temp_dir.path().join("stacking-preview.png"); + let response = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "export_preview".to_string(), + args: serde_json::json!({ + "path": target_path, + "slide_index": 0 + }), + }, + temp_dir.path(), + )?; + let preview = image::open( + response + .exported_paths + .first() + .expect("exported preview path present"), + )?; + + assert_eq!(preview.get_pixel(200, 220).0, [0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(preview.get_pixel(200, 290).0, [0xFF, 0xFF, 0xFF, 0xFF]); + assert_eq!(preview.get_pixel(122, 202).0, [0xD9, 0xEA, 0xF7, 0xFF]); + Ok(()) +} + +#[test] +fn render_preview_respects_cross_element_z_order() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let image_path = temp_dir.path().join("z-order-source.png"); + image::RgbaImage::from_pixel(40, 40, image::Rgba([0x2A, 0x80, 0xD7, 0xFF])) + .save(&image_path)?; + + let mut manager = PresentationArtifactManager::default(); + let created = manager.execute( + PresentationArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Cross element z-order" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_slide".to_string(), + args: serde_json::json!({}), + }, + temp_dir.path(), + )?; + let added_image = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_image".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "path": image_path, + "position": { "left": 120, "top": 120, "width": 180, "height": 180 } + }), + }, + temp_dir.path(), + )?; + let image_id = added_image + .artifact_snapshot + .as_ref() + .and_then(|snapshot| snapshot.slides.first()) + .and_then(|slide| slide.element_ids.first()) + .cloned() + .expect("image id"); + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "add_shape".to_string(), + args: serde_json::json!({ + "slide_index": 0, + "geometry": "rectangle", + "position": { "left": 140, "top": 140, "width": 180, "height": 180 }, + "fill": "#FFFFFF" + }), + }, + temp_dir.path(), + )?; + let preview = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "render_preview".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + let rendered = load_preview(&preview.rendered_preview.expect("preview").png_bytes)?; + assert_eq!(rendered.get_pixel(180, 180).0, [0xFF, 0xFF, 0xFF, 0xFF]); + + manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "update_shape_style".to_string(), + args: serde_json::json!({ + "element_id": format!("im/{image_id}"), + "z_order": 1 + }), + }, + temp_dir.path(), + )?; + let updated_preview = manager.execute( + PresentationArtifactRequest { + artifact_id: Some(artifact_id), + action: "render_preview".to_string(), + args: serde_json::json!({ "slide_index": 0 }), + }, + temp_dir.path(), + )?; + let updated = load_preview( + &updated_preview + .rendered_preview + .expect("updated preview") + .png_bytes, + )?; + assert_eq!(updated.get_pixel(180, 180).0, [0x2A, 0x80, 0xD7, 0xFF]); + Ok(()) +} + #[test] fn export_preview_writes_all_slides_with_stable_names() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; diff --git a/codex-rs/artifact-spreadsheet/src/manager.rs b/codex-rs/artifact-spreadsheet/src/manager.rs index 392b685c48..7a7fa2456d 100644 --- a/codex-rs/artifact-spreadsheet/src/manager.rs +++ b/codex-rs/artifact-spreadsheet/src/manager.rs @@ -1704,7 +1704,7 @@ impl SpreadsheetArtifactManager { let args: CreateDifferentialFormatArgs = parse_args(&request.action, &request.args)?; let artifact_id = required_artifact_id(&request)?; let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; - let style_id = artifact.create_differential_format(args.format); + let style_id = artifact.create_differential_format(args.format)?; let format = artifact .get_differential_format(style_id) .cloned() @@ -2265,6 +2265,7 @@ impl SpreadsheetArtifactManager { let action = request.action.clone(); let artifact_id = required_artifact_id(&request)?; let artifact = self.get_artifact_mut(&artifact_id, &request.action)?; + artifact.validate_style_index(style_index, &request.action)?; { let sheet = artifact.sheet_lookup_mut( &request.action, diff --git a/codex-rs/artifact-spreadsheet/src/model.rs b/codex-rs/artifact-spreadsheet/src/model.rs index 747eedbcea..c38015375f 100644 --- a/codex-rs/artifact-spreadsheet/src/model.rs +++ b/codex-rs/artifact-spreadsheet/src/model.rs @@ -982,6 +982,9 @@ impl SpreadsheetSheet { for address in range.addresses() { let cell = self.get_or_create_cell_mut(address); cell.formula = formula.clone(); + if cell.formula.is_none() { + cell.value = None; + } } self.cells.retain(|_, cell| !cell.is_empty()); Ok(()) @@ -1094,6 +1097,9 @@ impl SpreadsheetSheet { }; let cell = self.get_or_create_cell_mut(address); cell.formula = formula.clone(); + if cell.formula.is_none() { + cell.value = None; + } } } self.cells.retain(|_, cell| !cell.is_empty()); @@ -1741,24 +1747,7 @@ impl SpreadsheetArtifact { } match selected.as_str() { - "xlsx" => { - for sheet in &self.sheets { - if !sheet.charts.is_empty() - || !sheet.tables.is_empty() - || !sheet.conditional_formats.is_empty() - || !sheet.pivot_tables.is_empty() - { - return Err(SpreadsheetArtifactError::ExportFailed { - path: path.to_path_buf(), - message: format!( - "xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead", - sheet.name - ), - }); - } - } - write_xlsx(self, path) - } + "xlsx" => write_xlsx(self, path), "json" => { let json = self.to_json()?; std::fs::write(path, json).map_err(|error| { diff --git a/codex-rs/artifact-spreadsheet/src/style.rs b/codex-rs/artifact-spreadsheet/src/style.rs index 0c9a3782a5..2b7857ecfc 100644 --- a/codex-rs/artifact-spreadsheet/src/style.rs +++ b/codex-rs/artifact-spreadsheet/src/style.rs @@ -239,6 +239,18 @@ impl SpreadsheetNumberFormat { } fn normalized(mut self) -> Self { + if let Some(format_id) = self.format_id + && let Some(format_code) = builtin_number_format_code(format_id) + { + self.format_code = Some(format_code); + return self; + } + if self.format_id.is_none() { + self.format_id = self + .format_code + .as_deref() + .and_then(builtin_number_format_id); + } if self.format_code.is_none() { self.format_code = self.format_id.and_then(builtin_number_format_code); } @@ -493,6 +505,7 @@ impl SpreadsheetArtifact { } else { format }; + self.validate_cell_format_references(&created, "create_cell_format")?; Ok(insert_with_next_id(&mut self.cell_formats, created)) } @@ -500,8 +513,12 @@ impl SpreadsheetArtifact { self.cell_formats.get(&format_id) } - pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 { - insert_with_next_id(&mut self.differential_formats, format) + pub fn create_differential_format( + &mut self, + format: SpreadsheetDifferentialFormat, + ) -> Result { + self.validate_differential_format_references(&format, "create_differential_format")?; + Ok(insert_with_next_id(&mut self.differential_formats, format)) } pub fn get_differential_format( @@ -534,6 +551,70 @@ impl SpreadsheetArtifact { wrap_text: resolved.wrap_text, }) } + + pub fn validate_style_index( + &self, + style_index: u32, + action: &str, + ) -> Result<(), SpreadsheetArtifactError> { + if style_index == 0 || self.cell_formats.contains_key(&style_index) { + return Ok(()); + } + Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("style index `{style_index}` was not found"), + }) + } + + fn validate_cell_format_references( + &self, + format: &SpreadsheetCellFormat, + action: &str, + ) -> Result<(), SpreadsheetArtifactError> { + validate_optional_reference( + format.text_style_id, + &self.text_styles, + "text style", + action, + )?; + validate_optional_reference(format.fill_id, &self.fills, "fill", action)?; + validate_optional_reference(format.border_id, &self.borders, "border", action)?; + validate_optional_reference( + format.number_format_id, + &self.number_formats, + "number format", + action, + )?; + validate_optional_reference( + format.base_cell_style_format_id, + &self.cell_formats, + "base cell format", + action, + )?; + Ok(()) + } + + fn validate_differential_format_references( + &self, + format: &SpreadsheetDifferentialFormat, + action: &str, + ) -> Result<(), SpreadsheetArtifactError> { + validate_optional_reference( + format.text_style_id, + &self.text_styles, + "text style", + action, + )?; + validate_optional_reference(format.fill_id, &self.fills, "fill", action)?; + validate_optional_reference(format.border_id, &self.borders, "border", action)?; + validate_optional_reference( + format.number_format_id, + &self.number_formats, + "number format", + action, + )?; + Ok(()) + } } impl SpreadsheetSheet { @@ -566,6 +647,23 @@ fn resolve_cell_format_recursive( }) } +fn validate_optional_reference( + id: Option, + map: &BTreeMap, + kind: &str, + action: &str, +) -> Result<(), SpreadsheetArtifactError> { + if let Some(id) = id + && !map.contains_key(&id) + { + return Err(SpreadsheetArtifactError::InvalidArgs { + action: action.to_string(), + message: format!("{kind} `{id}` was not found"), + }); + } + Ok(()) +} + fn builtin_number_format_code(format_id: u32) -> Option { match format_id { 0 => Some("General".to_string()), @@ -578,3 +676,16 @@ fn builtin_number_format_code(format_id: u32) -> Option { _ => None, } } + +fn builtin_number_format_id(format_code: &str) -> Option { + match format_code { + "General" => Some(0), + "0" => Some(1), + "0.00" => Some(2), + "#,##0" => Some(3), + "#,##0.00" => Some(4), + "0%" => Some(9), + "0.00%" => Some(10), + _ => None, + } +} diff --git a/codex-rs/artifact-spreadsheet/src/tests.rs b/codex-rs/artifact-spreadsheet/src/tests.rs index 608ac7b392..5c7499ddf2 100644 --- a/codex-rs/artifact-spreadsheet/src/tests.rs +++ b/codex-rs/artifact-spreadsheet/src/tests.rs @@ -505,6 +505,1100 @@ fn xlsx_roundtrip_preserves_row_and_column_sizes() -> Result<(), Box Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("styled-roundtrip.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Styled Roundtrip".to_string())); + let style_index = { + let text_style_id = artifact.create_text_style( + SpreadsheetTextStyle { + bold: Some(true), + italic: Some(true), + underline: Some(true), + font_size: Some(14.0), + font_color: Some("#112233".to_string()), + font_family: Some("Aptos".to_string()), + typeface: Some("Aptos".to_string()), + ..Default::default() + }, + None, + false, + )?; + let fill_id = artifact.create_fill( + SpreadsheetFill { + solid_fill_color: Some("#FFEEAA".to_string()), + pattern_type: Some("solid".to_string()), + pattern_foreground_color: Some("#FFEEAA".to_string()), + pattern_background_color: Some("#221100".to_string()), + ..Default::default() + }, + None, + false, + )?; + let border_id = artifact.create_border( + crate::SpreadsheetBorder { + top: Some(crate::SpreadsheetBorderLine { + style: Some("thin".to_string()), + color: Some("#111111".to_string()), + }), + right: Some(crate::SpreadsheetBorderLine { + style: Some("dashed".to_string()), + color: Some("#222222".to_string()), + }), + bottom: Some(crate::SpreadsheetBorderLine { + style: Some("double".to_string()), + color: Some("#333333".to_string()), + }), + left: Some(crate::SpreadsheetBorderLine { + style: Some("hair".to_string()), + color: Some("#444444".to_string()), + }), + }, + None, + false, + )?; + let number_format_id = artifact.create_number_format( + SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + }, + None, + false, + )?; + artifact.create_cell_format( + SpreadsheetCellFormat { + text_style_id: Some(text_style_id), + fill_id: Some(fill_id), + border_id: Some(border_id), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: Some("center".to_string()), + vertical: Some("bottom".to_string()), + }), + number_format_id: Some(number_format_id), + wrap_text: Some(true), + base_cell_style_format_id: None, + }, + None, + false, + )? + }; + + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Float(42.5)), + )?; + sheet.set_style_index(&CellRange::parse("A1")?, style_index)?; + sheet.set_style_index(&CellRange::parse("B2")?, style_index)?; + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + + assert_eq!( + restored_sheet + .get_raw_cell(CellAddress::parse("B2")?) + .map(|cell| cell.style_index), + Some(style_index) + ); + assert_eq!( + restored.cell_format_summary(style_index), + Some(SpreadsheetCellFormatSummary { + style_index, + text_style: Some(SpreadsheetTextStyle { + bold: Some(true), + italic: Some(true), + underline: Some(true), + font_size: Some(14.0), + font_color: Some("#112233".to_string()), + text_alignment: None, + anchor: None, + vertical_text_orientation: None, + text_rotation: None, + paragraph_spacing: None, + bottom_inset: None, + left_inset: None, + right_inset: None, + top_inset: None, + font_family: Some("Aptos".to_string()), + font_scheme: None, + typeface: Some("Aptos".to_string()), + font_face: None, + }), + fill: Some(SpreadsheetFill { + solid_fill_color: Some("#FFEEAA".to_string()), + pattern_type: Some("solid".to_string()), + pattern_foreground_color: Some("#FFEEAA".to_string()), + pattern_background_color: Some("#221100".to_string()), + color_transforms: Vec::new(), + gradient_fill_type: None, + gradient_stops: Vec::new(), + gradient_kind: None, + angle: None, + scaled: None, + path_type: None, + fill_rectangle: None, + image_reference: None, + }), + border: Some(crate::SpreadsheetBorder { + top: Some(crate::SpreadsheetBorderLine { + style: Some("thin".to_string()), + color: Some("#111111".to_string()), + }), + right: Some(crate::SpreadsheetBorderLine { + style: Some("dashed".to_string()), + color: Some("#222222".to_string()), + }), + bottom: Some(crate::SpreadsheetBorderLine { + style: Some("double".to_string()), + color: Some("#333333".to_string()), + }), + left: Some(crate::SpreadsheetBorderLine { + style: Some("hair".to_string()), + color: Some("#444444".to_string()), + }), + }), + alignment: Some(crate::SpreadsheetAlignment { + horizontal: Some("center".to_string()), + vertical: Some("bottom".to_string()), + }), + number_format: Some(SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + }), + wrap_text: Some(true), + }) + ); + Ok(()) +} + +#[test] +fn xlsx_roundtrip_preserves_custom_sheet_metadata() -> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("metadata-roundtrip.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Metadata".to_string())); + { + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("Quarter".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::String("Revenue".to_string())), + )?; + sheet.set_value( + CellAddress::parse("C1")?, + Some(SpreadsheetCellValue::String("Forecast".to_string())), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::String("Q1".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(120)), + )?; + sheet.set_value( + CellAddress::parse("C2")?, + Some(SpreadsheetCellValue::Integer(118)), + )?; + sheet.create_table( + "test_create_table", + &CellRange::parse("A1:C2")?, + crate::SpreadsheetCreateTableOptions { + name: Some("Metrics".to_string()), + display_name: Some("Metrics".to_string()), + header_row_count: 1, + totals_row_count: 0, + style_name: Some("TableStyleMedium2".to_string()), + show_row_stripes: true, + ..Default::default() + }, + )?; + sheet.create_chart( + "test_create_chart", + crate::SpreadsheetChartType::Line, + Some("Sheet1".to_string()), + &CellRange::parse("A1:C2")?, + crate::SpreadsheetChartCreateOptions { + title: Some("Revenue vs Forecast".to_string()), + legend_position: Some(crate::SpreadsheetChartLegendPosition::Right), + ..Default::default() + }, + )?; + sheet.pivot_tables.push(crate::SpreadsheetPivotTable { + name: "Pivot1".to_string(), + cache_id: 7, + address: Some("E1:G5".to_string()), + row_fields: vec![crate::SpreadsheetPivotFieldReference { + field_index: 0, + field_name: Some("Quarter".to_string()), + }], + column_fields: Vec::new(), + page_fields: Vec::new(), + data_fields: vec![crate::SpreadsheetPivotDataField { + field_index: 1, + field_name: Some("Revenue".to_string()), + name: Some("Sum of Revenue".to_string()), + subtotal: Some("sum".to_string()), + }], + filters: Vec::new(), + pivot_fields: vec![crate::SpreadsheetPivotField { + index: 0, + name: Some("Quarter".to_string()), + axis: Some("axisRow".to_string()), + items: vec![crate::SpreadsheetPivotFieldItem { + item_type: Some("default".to_string()), + index: Some(0), + hidden: false, + }], + }], + style_name: Some("PivotStyleLight16".to_string()), + part_path: Some("xl/pivotTables/pivotTable1.xml".to_string()), + }); + } + artifact.add_conditional_format( + "test_add_conditional_format", + "Sheet1", + crate::SpreadsheetConditionalFormat { + id: 0, + range: "B2:C2".to_string(), + rule_type: crate::SpreadsheetConditionalFormatType::ColorScale, + operator: None, + formulas: Vec::new(), + text: None, + dxf_id: None, + stop_if_true: false, + priority: 0, + rank: None, + percent: None, + time_period: None, + above_average: None, + equal_average: None, + color_scale: Some(crate::SpreadsheetColorScale { + min_type: Some("min".to_string()), + mid_type: Some("percentile".to_string()), + max_type: Some("max".to_string()), + min_value: None, + mid_value: Some("50".to_string()), + max_value: None, + min_color: "#FFF2CC".to_string(), + mid_color: Some("#FFD966".to_string()), + max_color: "#BF9000".to_string(), + }), + data_bar: None, + icon_set: None, + }, + )?; + + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.tables.len(), 1); + assert_eq!(restored_sheet.tables[0].name, "Metrics".to_string()); + assert_eq!(restored_sheet.charts.len(), 1); + assert_eq!( + restored_sheet.charts[0].title, + Some("Revenue vs Forecast".to_string()) + ); + assert_eq!(restored_sheet.conditional_formats.len(), 1); + assert_eq!( + restored_sheet.conditional_formats[0].rule_type, + crate::SpreadsheetConditionalFormatType::ColorScale + ); + assert_eq!(restored_sheet.pivot_tables.len(), 1); + assert_eq!(restored_sheet.pivot_tables[0].cache_id, 7); + Ok(()) +} + +#[test] +fn xlsx_roundtrip_preserves_conditional_format_differential_styles() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("conditional-dxf-roundtrip.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Conditional DXF".to_string())); + let fill_id = artifact.create_fill( + SpreadsheetFill { + solid_fill_color: Some("#FFCCCC".to_string()), + pattern_type: Some("solid".to_string()), + pattern_foreground_color: Some("#FFCCCC".to_string()), + ..Default::default() + }, + None, + false, + )?; + let dxf_id = artifact.create_differential_format(crate::SpreadsheetDifferentialFormat { + text_style_id: None, + fill_id: Some(fill_id), + border_id: None, + alignment: None, + number_format_id: None, + wrap_text: None, + })?; + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(12)), + )?; + artifact.add_conditional_format( + "test_add_conditional_format", + "Sheet1", + crate::SpreadsheetConditionalFormat { + id: 0, + range: "A1:A1".to_string(), + rule_type: crate::SpreadsheetConditionalFormatType::Expression, + operator: None, + formulas: vec!["A1>10".to_string()], + text: None, + dxf_id: Some(dxf_id), + stop_if_true: true, + priority: 0, + rank: None, + percent: None, + time_period: None, + above_average: None, + equal_average: None, + color_scale: None, + data_bar: None, + icon_set: None, + }, + )?; + + artifact.export(&path)?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.conditional_formats.len(), 1); + assert_eq!( + restored_sheet.conditional_formats[0], + crate::SpreadsheetConditionalFormat { + id: 1, + range: "A1:A1".to_string(), + rule_type: crate::SpreadsheetConditionalFormatType::Expression, + operator: None, + formulas: vec!["A1>10".to_string()], + text: None, + dxf_id: Some(dxf_id), + stop_if_true: true, + priority: 1, + rank: None, + percent: None, + time_period: None, + above_average: None, + equal_average: None, + color_scale: None, + data_bar: None, + icon_set: None, + } + ); + assert_eq!( + restored.get_differential_format(dxf_id), + Some(&crate::SpreadsheetDifferentialFormat { + text_style_id: None, + fill_id: Some(fill_id), + border_id: None, + alignment: None, + number_format_id: None, + wrap_text: None, + }) + ); + Ok(()) +} + +#[test] +fn native_xlsx_table_import_reconstructs_table_metadata() -> Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("native-table.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Native Table".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("Quarter".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::String("Revenue".to_string())), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::String("Q1".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(120)), + )?; + artifact.export(&path)?; + + let sheet_xml = read_zip_entry_for_test(&path, "xl/worksheets/sheet1.xml")?; + rewrite_xlsx_entries( + &path, + &BTreeMap::from([ + ( + "[Content_Types].xml".to_string(), + inject_before( + &read_zip_entry_for_test(&path, "[Content_Types].xml")?, + "", + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml".to_string(), + inject_before( + &sheet_xml, + "", + r#""#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/tables/table1.xml".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#"
"# + ) + .to_string(), + ), + ]), + )?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.tables.len(), 1); + assert_eq!(restored_sheet.tables[0].name, "SalesTable".to_string()); + assert_eq!( + restored_sheet.tables[0].style_name, + Some("TableStyleMedium2".to_string()) + ); + assert_eq!( + restored_sheet.tables[0].columns, + vec![ + crate::SpreadsheetTableColumn { + id: 1, + name: "Quarter".to_string(), + totals_row_label: None, + totals_row_function: None, + }, + crate::SpreadsheetTableColumn { + id: 2, + name: "Revenue".to_string(), + totals_row_label: None, + totals_row_function: Some("sum".to_string()), + }, + ] + ); + Ok(()) +} + +#[test] +fn native_xlsx_conditional_format_import_reconstructs_rules_and_differential_styles() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("native-conditional-format.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Native Conditional".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::Integer(5)), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::Integer(15)), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::Integer(1)), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(10)), + )?; + artifact.export(&path)?; + + let styles_xml = read_zip_entry_for_test(&path, "xl/styles.xml")?; + let sheet_xml = read_zip_entry_for_test(&path, "xl/worksheets/sheet1.xml")?; + rewrite_xlsx_entries( + &path, + &BTreeMap::from([ + ( + "xl/styles.xml".to_string(), + styles_xml.replacen( + r#""#, + concat!( + r#""#, + r#""#, + r#""# + ), + 1, + ), + ), + ( + "xl/worksheets/sheet1.xml".to_string(), + inject_before( + &sheet_xml, + "", + concat!( + r#""#, + r#"A1>10"#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ), + ), + ), + ]), + )?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!( + restored_sheet.conditional_formats, + vec![ + crate::SpreadsheetConditionalFormat { + id: 1, + range: "A1:A2".to_string(), + rule_type: crate::SpreadsheetConditionalFormatType::Expression, + operator: None, + formulas: vec!["A1>10".to_string()], + text: None, + dxf_id: Some(1), + stop_if_true: true, + priority: 1, + rank: None, + percent: None, + time_period: None, + above_average: None, + equal_average: None, + color_scale: None, + data_bar: None, + icon_set: None, + }, + crate::SpreadsheetConditionalFormat { + id: 2, + range: "B1:B2".to_string(), + rule_type: crate::SpreadsheetConditionalFormatType::ColorScale, + operator: None, + formulas: Vec::new(), + text: None, + dxf_id: None, + stop_if_true: false, + priority: 2, + rank: None, + percent: None, + time_period: None, + above_average: None, + equal_average: None, + color_scale: Some(crate::SpreadsheetColorScale { + min_type: Some("min".to_string()), + mid_type: None, + max_type: Some("max".to_string()), + min_value: None, + mid_value: None, + max_value: None, + min_color: "#FF7128".to_string(), + mid_color: None, + max_color: "#63BE7B".to_string(), + }), + data_bar: None, + icon_set: None, + }, + ] + ); + assert_eq!( + restored.get_differential_format(1), + Some(&crate::SpreadsheetDifferentialFormat { + text_style_id: None, + fill_id: Some(1), + border_id: None, + alignment: None, + number_format_id: None, + wrap_text: None, + }) + ); + Ok(()) +} + +#[test] +fn native_xlsx_chart_import_reconstructs_chart_metadata() -> Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("native-chart.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Native Chart".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("Quarter".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::String("Revenue".to_string())), + )?; + sheet.set_value( + CellAddress::parse("C1")?, + Some(SpreadsheetCellValue::String("Forecast".to_string())), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::String("Q1".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(120)), + )?; + sheet.set_value( + CellAddress::parse("C2")?, + Some(SpreadsheetCellValue::Integer(118)), + )?; + sheet.set_value( + CellAddress::parse("A3")?, + Some(SpreadsheetCellValue::String("Q2".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B3")?, + Some(SpreadsheetCellValue::Integer(134)), + )?; + sheet.set_value( + CellAddress::parse("C3")?, + Some(SpreadsheetCellValue::Integer(130)), + )?; + artifact.export(&path)?; + + rewrite_xlsx_entries( + &path, + &BTreeMap::from([ + ( + "xl/worksheets/sheet1.xml".to_string(), + inject_before( + &read_zip_entry_for_test(&path, "xl/worksheets/sheet1.xml")?, + "", + r#""#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/drawings/drawing1.xml".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/drawings/_rels/drawing1.xml.rels".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/charts/chart1.xml".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#"Quarterly Revenue"#, + r#""#, + r#"RevenueSheet1!$A$1:$A$3Sheet1!$B$1:$B$3"#, + r#"ForecastSheet1!$A$1:$A$3Sheet1!$C$1:$C$3"#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ]), + )?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.charts.len(), 1); + let chart = &restored_sheet.charts[0]; + assert_eq!(chart.chart_type, crate::SpreadsheetChartType::Line); + assert_eq!(chart.title, Some("Quarterly Revenue".to_string())); + assert_eq!(chart.source_sheet_name, Some("Sheet1".to_string())); + assert_eq!(chart.source_range, Some("A1:C3".to_string())); + assert_eq!( + chart.legend.position, + crate::SpreadsheetChartLegendPosition::Right + ); + assert_eq!(chart.series.len(), 2); + assert_eq!(chart.series[0].name, Some("Revenue".to_string())); + assert_eq!(chart.series[1].value_range, "C1:C3".to_string()); + Ok(()) +} + +#[test] +fn native_xlsx_pivot_import_reconstructs_pivot_metadata() -> Result<(), Box> +{ + let temp_dir = tempfile::tempdir()?; + let path = temp_dir.path().join("native-pivot.xlsx"); + + let mut artifact = SpreadsheetArtifact::new(Some("Native Pivot".to_string())); + let sheet = artifact.create_sheet("Sheet1".to_string())?; + sheet.set_value( + CellAddress::parse("A1")?, + Some(SpreadsheetCellValue::String("Quarter".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B1")?, + Some(SpreadsheetCellValue::String("Revenue".to_string())), + )?; + sheet.set_value( + CellAddress::parse("A2")?, + Some(SpreadsheetCellValue::String("Q1".to_string())), + )?; + sheet.set_value( + CellAddress::parse("B2")?, + Some(SpreadsheetCellValue::Integer(120)), + )?; + artifact.export(&path)?; + + rewrite_xlsx_entries( + &path, + &BTreeMap::from([ + ( + "xl/workbook.xml".to_string(), + inject_before( + &read_zip_entry_for_test(&path, "xl/workbook.xml")?, + "", + r#""#, + ), + ), + ( + "xl/_rels/workbook.xml.rels".to_string(), + inject_before( + &read_zip_entry_for_test(&path, "xl/_rels/workbook.xml.rels")?, + "", + r#""#, + ), + ), + ( + "xl/worksheets/sheet1.xml".to_string(), + inject_before( + &read_zip_entry_for_test(&path, "xl/worksheets/sheet1.xml")?, + "", + r#""#, + ), + ), + ( + "xl/worksheets/_rels/sheet1.xml.rels".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/pivotCache/pivotCacheDefinition1.xml".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ( + "xl/pivotTables/pivotTable1.xml".to_string(), + concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""# + ) + .to_string(), + ), + ]), + )?; + + let restored = SpreadsheetArtifact::from_source_file(&path, None)?; + let restored_sheet = restored.get_sheet(Some("Sheet1"), None).expect("sheet"); + assert_eq!(restored_sheet.pivot_tables.len(), 1); + let pivot = &restored_sheet.pivot_tables[0]; + assert_eq!(pivot.name, "SalesPivot".to_string()); + assert_eq!(pivot.cache_id, 7); + assert_eq!(pivot.address, Some("E3:G8".to_string())); + assert_eq!( + pivot.row_fields, + vec![crate::SpreadsheetPivotFieldReference { + field_index: 0, + field_name: Some("Quarter".to_string()), + }] + ); + assert_eq!( + pivot.data_fields, + vec![crate::SpreadsheetPivotDataField { + field_index: 1, + field_name: Some("Revenue".to_string()), + name: Some("Sum of Revenue".to_string()), + subtotal: Some("sum".to_string()), + }] + ); + assert_eq!(pivot.style_name, Some("PivotStyleLight16".to_string())); + Ok(()) +} + +#[test] +fn clearing_range_formulas_removes_stale_cached_values() -> Result<(), Box> { + let mut sheet = SpreadsheetSheet::new("Sheet1".to_string()); + let range = CellRange::parse("A1:B1")?; + + sheet.set_values_matrix( + &range, + &[vec![ + Some(SpreadsheetCellValue::Integer(1)), + Some(SpreadsheetCellValue::Integer(2)), + ]], + )?; + sheet.set_formulas_matrix(&range, &[vec![None, None]])?; + assert_eq!(sheet.cells, BTreeMap::new()); + + sheet.set_range_to_value(&range, Some(SpreadsheetCellValue::Integer(9)))?; + sheet.set_range_to_formula(&range, None)?; + assert_eq!(sheet.cells, BTreeMap::new()); + Ok(()) +} + +#[test] +fn style_validation_rejects_missing_references_and_unknown_style_indices() +-> Result<(), Box> { + let temp_dir = tempfile::tempdir()?; + let mut manager = SpreadsheetArtifactManager::default(); + let created = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: None, + action: "create".to_string(), + args: serde_json::json!({ "name": "Style Validation" }), + }, + temp_dir.path(), + )?; + let artifact_id = created.artifact_id; + + manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_sheet".to_string(), + args: serde_json::json!({ "name": "Sheet1" }), + }, + temp_dir.path(), + )?; + + let invalid_cell_format = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_cell_format".to_string(), + args: serde_json::json!({ + "format": { + "text_style_id": 999 + } + }), + }, + temp_dir.path(), + ); + assert_eq!( + invalid_cell_format.unwrap_err().to_string(), + "invalid args for action `create_cell_format`: text style `999` was not found" + ); + + let invalid_dxf = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id.clone()), + action: "create_differential_format".to_string(), + args: serde_json::json!({ + "format": { + "fill_id": 999 + } + }), + }, + temp_dir.path(), + ); + assert_eq!( + invalid_dxf.unwrap_err().to_string(), + "invalid args for action `create_differential_format`: fill `999` was not found" + ); + + let invalid_style_assignment = manager.execute( + SpreadsheetArtifactRequest { + artifact_id: Some(artifact_id), + action: "set_cell_style".to_string(), + args: serde_json::json!({ + "sheet_name": "Sheet1", + "address": "A1", + "style_index": 999 + }), + }, + temp_dir.path(), + ); + assert_eq!( + invalid_style_assignment.unwrap_err().to_string(), + "invalid args for action `set_cell_style`: style index `999` was not found" + ); + Ok(()) +} + +#[test] +fn builtin_number_formats_are_normalized_consistently() -> Result<(), Box> { + let mut artifact = SpreadsheetArtifact::new(Some("Number Formats".to_string())); + + let by_id = artifact.create_number_format( + SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("not-the-builtin-code".to_string()), + }, + None, + false, + )?; + assert_eq!( + artifact.get_number_format(by_id), + Some(&SpreadsheetNumberFormat { + format_id: Some(4), + format_code: Some("#,##0.00".to_string()), + }) + ); + + let by_code = artifact.create_number_format( + SpreadsheetNumberFormat { + format_id: None, + format_code: Some("0.00%".to_string()), + }, + None, + false, + )?; + assert_eq!( + artifact.get_number_format(by_code), + Some(&SpreadsheetNumberFormat { + format_id: Some(10), + format_code: Some("0.00%".to_string()), + }) + ); + Ok(()) +} + +fn read_zip_entry_for_test( + path: &std::path::Path, + entry_name: &str, +) -> Result> { + let file = std::fs::File::open(path)?; + let mut archive = zip::ZipArchive::new(file)?; + let mut entry = archive.by_name(entry_name)?; + let mut contents = String::new(); + use std::io::Read as _; + entry.read_to_string(&mut contents)?; + Ok(contents) +} + +fn rewrite_xlsx_entries( + path: &std::path::Path, + replacements: &BTreeMap, +) -> Result<(), Box> { + let temp_path = path.with_extension("rewritten.xlsx"); + let source = std::fs::File::open(path)?; + let mut archive = zip::ZipArchive::new(source)?; + let target = std::fs::File::create(&temp_path)?; + let mut writer = zip::ZipWriter::new(target); + let options = zip::write::SimpleFileOptions::default(); + let mut seen = std::collections::BTreeSet::new(); + + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + let name = entry.name().to_string(); + seen.insert(name.clone()); + writer.start_file(&name, options)?; + if let Some(replacement) = replacements.get(&name) { + use std::io::Write as _; + writer.write_all(replacement.as_bytes())?; + } else { + std::io::copy(&mut entry, &mut writer)?; + } + } + + for (name, replacement) in replacements { + if seen.contains(name) { + continue; + } + writer.start_file(name, options)?; + use std::io::Write as _; + writer.write_all(replacement.as_bytes())?; + } + + writer.finish()?; + std::fs::rename(temp_path, path)?; + Ok(()) +} + +fn inject_before(original: &str, marker: &str, addition: &str) -> String { + original.replacen(marker, &format!("{addition}{marker}"), 1) +} + #[test] fn manager_supports_bulk_sizes_and_row_heights() -> Result<(), Box> { let temp_dir = tempfile::tempdir()?; diff --git a/codex-rs/artifact-spreadsheet/src/xlsx.rs b/codex-rs/artifact-spreadsheet/src/xlsx.rs index 5175f3b51e..1156501843 100644 --- a/codex-rs/artifact-spreadsheet/src/xlsx.rs +++ b/codex-rs/artifact-spreadsheet/src/xlsx.rs @@ -12,11 +12,22 @@ use zip::write::SimpleFileOptions; use crate::CellAddress; use crate::CellRange; +use crate::SpreadsheetAlignment; use crate::SpreadsheetArtifact; use crate::SpreadsheetArtifactError; +use crate::SpreadsheetBorder; +use crate::SpreadsheetBorderLine; use crate::SpreadsheetCell; +use crate::SpreadsheetCellFormat; use crate::SpreadsheetCellValue; +use crate::SpreadsheetFill; +use crate::SpreadsheetFillRectangle; +use crate::SpreadsheetGradientStop; +use crate::SpreadsheetNumberFormat; use crate::SpreadsheetSheet; +use crate::SpreadsheetTextStyle; + +const CODEX_METADATA_PATH: &str = "customXml/item1.xml"; pub(crate) fn write_xlsx( artifact: &mut SpreadsheetArtifact, @@ -42,11 +53,13 @@ pub(crate) fn write_xlsx( path: path.to_path_buf(), message: error.to_string(), })?; - zip.write_all(content_types_xml(sheet_count).as_bytes()) - .map_err(|error| SpreadsheetArtifactError::ExportFailed { - path: path.to_path_buf(), - message: error.to_string(), - })?; + zip.write_all( + content_types_xml(sheet_count, artifact_has_custom_metadata(artifact)).as_bytes(), + ) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; zip.add_directory("_rels/", options).map_err(|error| { SpreadsheetArtifactError::ExportFailed { @@ -139,6 +152,25 @@ pub(crate) fn write_xlsx( message: error.to_string(), })?; + if artifact_has_custom_metadata(artifact) { + zip.add_directory("customXml/", options).map_err(|error| { + SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + zip.start_file(CODEX_METADATA_PATH, options) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + zip.write_all(codex_metadata_xml(artifact)?.as_bytes()) + .map_err(|error| SpreadsheetArtifactError::ExportFailed { + path: path.to_path_buf(), + message: error.to_string(), + })?; + } + zip.add_directory("xl/worksheets/", options) .map_err(|error| SpreadsheetArtifactError::ExportFailed { path: path.to_path_buf(), @@ -211,6 +243,21 @@ pub(crate) fn import_xlsx( } else { None }; + let parsed_styles = if archive.by_name("xl/styles.xml").is_ok() { + parse_styles(&read_zip_entry(&mut archive, "xl/styles.xml", path)?)? + } else { + ParsedStyles::default() + }; + let pivot_caches = parse_pivot_caches(&mut archive, path, &workbook_xml, &workbook_rels)?; + let custom_metadata = if archive.by_name(CODEX_METADATA_PATH).is_ok() { + Some(parse_codex_metadata(&read_zip_entry( + &mut archive, + CODEX_METADATA_PATH, + path, + )?)?) + } else { + None + }; let relationships = parse_relationships(&workbook_rels)?; let sheets = parse_sheet_definitions(&workbook_xml)? @@ -242,10 +289,37 @@ pub(crate) fn import_xlsx( artifact.artifact_id = artifact_id; } artifact.sheets.clear(); + artifact.text_styles = parsed_styles.text_styles; + artifact.fills = parsed_styles.fills; + artifact.borders = parsed_styles.borders; + artifact.number_formats = parsed_styles.number_formats; + artifact.cell_formats = parsed_styles.cell_formats; + artifact.differential_formats = parsed_styles.differential_formats; + let dxf_id_map = parsed_styles.dxf_id_map; + + if let Some(metadata) = custom_metadata.as_ref() + && !metadata.differential_formats.is_empty() + { + artifact.differential_formats = metadata.differential_formats.clone(); + } for (name, target) in sheets { let xml = read_zip_entry(&mut archive, &target, path)?; - let sheet = parse_sheet(&name, &xml, shared_strings.as_deref())?; + let mut sheet = parse_sheet(&name, &xml, shared_strings.as_deref())?; + sheet.charts = parse_native_charts(&mut archive, path, &target, &xml, &name)?; + sheet.tables = parse_native_tables(&mut archive, path, &target, &xml)?; + sheet.conditional_formats = parse_native_conditional_formats(&xml, &dxf_id_map)?; + sheet.pivot_tables = + parse_native_pivot_tables(&mut archive, path, &target, &xml, &pivot_caches)?; + if let Some(metadata) = custom_metadata + .as_ref() + .and_then(|entry| entry.sheets.get(&name)) + { + sheet.charts = metadata.charts.clone(); + sheet.tables = metadata.tables.clone(); + sheet.conditional_formats = metadata.conditional_formats.clone(); + sheet.pivot_tables = metadata.pivot_tables.clone(); + } artifact.sheets.push(sheet); } @@ -375,50 +449,27 @@ fn parse_sheet( } } - let row_regex = Regex::new(r#"(?s)]*)>(.*?)"#).map_err(|error| { - SpreadsheetArtifactError::Serialization { - message: error.to_string(), - } - })?; - let cell_regex = Regex::new(r#"(?s)]*)>(.*?)"#).map_err(|error| { - SpreadsheetArtifactError::Serialization { - message: error.to_string(), - } - })?; - for row_captures in row_regex.captures_iter(xml) { - let row_attributes = row_captures - .get(1) - .map(|value| value.as_str()) - .unwrap_or_default(); + for (row_attributes, row_body) in child_tags(xml, "row")? { if let Some(row_index) = - extract_attribute(row_attributes, "r").and_then(|value| value.parse::().ok()) + extract_attribute(&row_attributes, "r").and_then(|value| value.parse::().ok()) && let Some(height) = - extract_attribute(row_attributes, "ht").and_then(|value| value.parse::().ok()) + extract_attribute(&row_attributes, "ht").and_then(|value| value.parse::().ok()) && row_index > 0 && height > 0.0 { sheet.row_heights.insert(row_index, height); } - let Some(row_body) = row_captures.get(2).map(|value| value.as_str()) else { - continue; - }; - for cell_captures in cell_regex.captures_iter(row_body) { - let Some(attributes) = cell_captures.get(1).map(|value| value.as_str()) else { - continue; - }; - let Some(body) = cell_captures.get(2).map(|value| value.as_str()) else { - continue; - }; - let Some(address) = extract_attribute(attributes, "r") else { + for (attributes, body) in child_tags(&row_body, "c")? { + let Some(address) = extract_attribute(&attributes, "r") else { continue; }; let address = CellAddress::parse(&address)?; - let style_index = extract_attribute(attributes, "s") + let style_index = extract_attribute(&attributes, "s") .and_then(|value| value.parse::().ok()) .unwrap_or(0); - let cell_type = extract_attribute(attributes, "t").unwrap_or_default(); - let formula = first_tag_text(body, "f").map(|value| format!("={value}")); - let value = parse_cell_value(body, &cell_type, shared_strings)?; + let cell_type = extract_attribute(&attributes, "t").unwrap_or_default(); + let formula = first_tag_text(&body, "f").map(|value| format!("={value}")); + let value = parse_cell_value(&body, &cell_type, shared_strings)?; let cell = SpreadsheetCell { value, @@ -454,7 +505,10 @@ fn parse_cell_value( cell_type: &str, shared_strings: Option<&[String]>, ) -> Result, SpreadsheetArtifactError> { - let inline_text = first_tag_text(body, "t").map(|value| xml_unescape(&value)); + let inline_text = (!body.is_empty()) + .then(|| all_text_nodes(body)) + .transpose()? + .filter(|value| !value.is_empty()); let raw_value = first_tag_text(body, "v").map(|value| xml_unescape(&value)); let parsed = match cell_type { @@ -482,13 +536,1385 @@ fn parse_cell_value( Ok(parsed) } -fn content_types_xml(sheet_count: usize) -> String { +fn parse_native_tables( + archive: &mut ZipArchive, + workbook_path: &Path, + sheet_part: &str, + sheet_xml: &str, +) -> Result, SpreadsheetArtifactError> { + let rels_path = sheet_relationships_path(sheet_part)?; + if archive.by_name(&rels_path).is_err() { + return Ok(Vec::new()); + } + + let relationships = parse_relationships(&read_zip_entry(archive, &rels_path, workbook_path)?)?; + child_tags(sheet_xml, "tablePart")? + .into_iter() + .filter_map(|(attributes, _)| { + extract_attribute(&attributes, "r:id").or_else(|| extract_attribute(&attributes, "id")) + }) + .filter_map(|relationship_id| { + relationships.get(&relationship_id).map(|target| { + normalize_relationship_target(sheet_part, target) + .map(|normalized| (relationship_id, normalized)) + }) + }) + .map(|entry| { + let (_, table_path) = entry?; + let xml = read_zip_entry(archive, &table_path, workbook_path)?; + parse_native_table(&xml) + }) + .collect() +} + +fn parse_native_conditional_formats( + sheet_xml: &str, + dxf_id_map: &BTreeMap, +) -> Result, SpreadsheetArtifactError> { + let mut formats = Vec::new(); + let mut next_id = 1; + + for (attributes, body) in child_tags(sheet_xml, "conditionalFormatting")? { + let ranges = extract_attribute(&attributes, "sqref") + .unwrap_or_default() + .split_whitespace() + .map(normalize_a1_reference) + .collect::>(); + if ranges.is_empty() { + continue; + } + + for (rule_attributes, rule_body) in child_tags(&body, "cfRule")? { + let Some(rule_type) = extract_attribute(&rule_attributes, "type") + .as_deref() + .and_then(parse_conditional_format_type) + else { + continue; + }; + let formulas = child_tags(&rule_body, "formula")? + .into_iter() + .map(|(_, formula)| xml_unescape(formula.trim())) + .filter(|formula| !formula.is_empty()) + .collect::>(); + let color_scale = (rule_type == crate::SpreadsheetConditionalFormatType::ColorScale) + .then(|| parse_color_scale_rule(&rule_body)) + .transpose()? + .flatten(); + let data_bar = (rule_type == crate::SpreadsheetConditionalFormatType::DataBar) + .then(|| parse_data_bar_rule(&rule_body)) + .transpose()? + .flatten(); + let icon_set = (rule_type == crate::SpreadsheetConditionalFormatType::IconSet) + .then(|| parse_icon_set_rule(&rule_body)) + .transpose()? + .flatten(); + let dxf_id = extract_attribute(&rule_attributes, "dxfId") + .and_then(|value| value.parse::().ok()) + .and_then(|value| dxf_id_map.get(&value).copied()); + if dxf_id.is_none() && color_scale.is_none() && data_bar.is_none() && icon_set.is_none() + { + continue; + } + let stop_if_true = extract_attribute(&rule_attributes, "stopIfTrue") + .and_then(|value| parse_xlsx_bool(&value)) + .unwrap_or(false); + let priority = extract_attribute(&rule_attributes, "priority") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let rank = extract_attribute(&rule_attributes, "rank") + .and_then(|value| value.parse::().ok()); + let percent = extract_attribute(&rule_attributes, "percent") + .and_then(|value| parse_xlsx_bool(&value)); + let time_period = extract_attribute(&rule_attributes, "timePeriod"); + let above_average = extract_attribute(&rule_attributes, "aboveAverage") + .and_then(|value| parse_xlsx_bool(&value)); + let equal_average = extract_attribute(&rule_attributes, "equalAverage") + .and_then(|value| parse_xlsx_bool(&value)); + let text = extract_attribute(&rule_attributes, "text"); + + for range in &ranges { + formats.push(crate::SpreadsheetConditionalFormat { + id: next_id, + range: range.clone(), + rule_type, + operator: extract_attribute(&rule_attributes, "operator"), + formulas: formulas.clone(), + text: text.clone(), + dxf_id, + stop_if_true, + priority: if priority == 0 { next_id } else { priority }, + rank, + percent, + time_period: time_period.clone(), + above_average, + equal_average, + color_scale: color_scale.clone(), + data_bar: data_bar.clone(), + icon_set: icon_set.clone(), + }); + next_id += 1; + } + } + } + + Ok(formats) +} + +fn parse_conditional_format_type(value: &str) -> Option { + match value { + "expression" => Some(crate::SpreadsheetConditionalFormatType::Expression), + "cellIs" => Some(crate::SpreadsheetConditionalFormatType::CellIs), + "colorScale" => Some(crate::SpreadsheetConditionalFormatType::ColorScale), + "dataBar" => Some(crate::SpreadsheetConditionalFormatType::DataBar), + "iconSet" => Some(crate::SpreadsheetConditionalFormatType::IconSet), + "top10" => Some(crate::SpreadsheetConditionalFormatType::Top10), + "uniqueValues" => Some(crate::SpreadsheetConditionalFormatType::UniqueValues), + "duplicateValues" => Some(crate::SpreadsheetConditionalFormatType::DuplicateValues), + "containsText" => Some(crate::SpreadsheetConditionalFormatType::ContainsText), + "notContainsText" => Some(crate::SpreadsheetConditionalFormatType::NotContainsText), + "beginsWith" => Some(crate::SpreadsheetConditionalFormatType::BeginsWith), + "endsWith" => Some(crate::SpreadsheetConditionalFormatType::EndsWith), + "containsBlanks" => Some(crate::SpreadsheetConditionalFormatType::ContainsBlanks), + "notContainsBlanks" => Some(crate::SpreadsheetConditionalFormatType::NotContainsBlanks), + "containsErrors" => Some(crate::SpreadsheetConditionalFormatType::ContainsErrors), + "notContainsErrors" => Some(crate::SpreadsheetConditionalFormatType::NotContainsErrors), + "timePeriod" => Some(crate::SpreadsheetConditionalFormatType::TimePeriod), + "aboveAverage" => Some(crate::SpreadsheetConditionalFormatType::AboveAverage), + _ => None, + } +} + +fn parse_color_scale_rule( + rule_body: &str, +) -> Result, SpreadsheetArtifactError> { + let Some(color_scale) = first_tag_text(rule_body, "colorScale") else { + return Ok(None); + }; + let thresholds = child_tags(&color_scale, "cfvo")? + .into_iter() + .map(|(attributes, _)| { + ( + extract_attribute(&attributes, "type"), + extract_attribute(&attributes, "val"), + ) + }) + .collect::>(); + let colors = child_tags(&color_scale, "color")? + .into_iter() + .filter_map(|(attributes, _)| parse_color_value(&attributes)) + .collect::>(); + if thresholds.len() < 2 || colors.len() < 2 { + return Ok(None); + } + let mid = (thresholds.len() == 3 && colors.len() == 3).then_some(1); + Ok(Some(crate::SpreadsheetColorScale { + min_type: thresholds.first().and_then(|(kind, _)| kind.clone()), + mid_type: mid.and_then(|index| thresholds.get(index).and_then(|(kind, _)| kind.clone())), + max_type: thresholds.last().and_then(|(kind, _)| kind.clone()), + min_value: thresholds.first().and_then(|(_, value)| value.clone()), + mid_value: mid.and_then(|index| thresholds.get(index).and_then(|(_, value)| value.clone())), + max_value: thresholds.last().and_then(|(_, value)| value.clone()), + min_color: colors.first().cloned().unwrap_or_default(), + mid_color: mid.and_then(|index| colors.get(index).cloned()), + max_color: colors.last().cloned().unwrap_or_default(), + })) +} + +fn parse_data_bar_rule( + rule_body: &str, +) -> Result, SpreadsheetArtifactError> { + let Some(data_bar_body) = first_tag_text(rule_body, "dataBar") else { + return Ok(None); + }; + let Some(data_bar_attributes) = first_tag_attributes(rule_body, "dataBar") else { + return Ok(None); + }; + let Some(color) = first_tag_attributes(&data_bar_body, "color") + .and_then(|attributes| parse_color_value(&attributes)) + else { + return Ok(None); + }; + Ok(Some(crate::SpreadsheetDataBar { + color, + min_length: extract_attribute(&data_bar_attributes, "minLength") + .and_then(|value| value.parse::().ok()), + max_length: extract_attribute(&data_bar_attributes, "maxLength") + .and_then(|value| value.parse::().ok()), + show_value: extract_attribute(&data_bar_attributes, "showValue") + .and_then(|value| parse_xlsx_bool(&value)), + })) +} + +fn parse_icon_set_rule( + rule_body: &str, +) -> Result, SpreadsheetArtifactError> { + let Some(icon_set_attributes) = first_tag_attributes(rule_body, "iconSet") else { + return Ok(None); + }; + let Some(style) = extract_attribute(&icon_set_attributes, "iconSet") else { + return Ok(None); + }; + Ok(Some(crate::SpreadsheetIconSet { + style, + show_value: extract_attribute(&icon_set_attributes, "showValue") + .and_then(|value| parse_xlsx_bool(&value)), + reverse_order: extract_attribute(&icon_set_attributes, "reverse") + .and_then(|value| parse_xlsx_bool(&value)), + })) +} + +fn parse_native_charts( + archive: &mut ZipArchive, + workbook_path: &Path, + sheet_part: &str, + _sheet_xml: &str, + sheet_name: &str, +) -> Result, SpreadsheetArtifactError> { + let rels_path = sheet_relationships_path(sheet_part)?; + if archive.by_name(&rels_path).is_err() { + return Ok(Vec::new()); + } + + let relationships = parse_relationships(&read_zip_entry(archive, &rels_path, workbook_path)?)?; + let drawing_targets = relationships + .values() + .filter(|target| target.contains("drawing")) + .map(|target| normalize_relationship_target(sheet_part, target)) + .collect::, _>>()?; + + let mut charts = Vec::new(); + for drawing_target in drawing_targets { + let drawing_xml = read_zip_entry(archive, &drawing_target, workbook_path)?; + let drawing_rels_path = sheet_relationships_path(&drawing_target)?; + let drawing_relationships = if archive.by_name(&drawing_rels_path).is_ok() { + parse_relationships(&read_zip_entry(archive, &drawing_rels_path, workbook_path)?)? + } else { + BTreeMap::new() + }; + + for (relationship_id, _) in child_tags_ns(&drawing_xml, "chart")? { + let Some(chart_rel_id) = extract_attribute(&relationship_id, "r:id") + .or_else(|| extract_attribute(&relationship_id, "id")) + else { + continue; + }; + let Some(chart_target) = drawing_relationships.get(&chart_rel_id) else { + continue; + }; + let chart_path = normalize_relationship_target(&drawing_target, chart_target)?; + let chart_xml = read_zip_entry(archive, &chart_path, workbook_path)?; + charts.push(parse_native_chart( + &chart_xml, + &chart_path, + sheet_name, + charts.len() as u32 + 1, + )?); + } + } + Ok(charts) +} + +fn parse_native_pivot_tables( + archive: &mut ZipArchive, + workbook_path: &Path, + sheet_part: &str, + sheet_xml: &str, + pivot_caches: &BTreeMap, +) -> Result, SpreadsheetArtifactError> { + let rels_path = sheet_relationships_path(sheet_part)?; + if archive.by_name(&rels_path).is_err() { + return Ok(Vec::new()); + } + + let relationships = parse_relationships(&read_zip_entry(archive, &rels_path, workbook_path)?)?; + child_tags_ns(sheet_xml, "pivotTablePart")? + .into_iter() + .filter_map(|(attributes, _)| { + extract_attribute(&attributes, "r:id").or_else(|| extract_attribute(&attributes, "id")) + }) + .filter_map(|relationship_id| { + relationships.get(&relationship_id).map(|target| { + normalize_relationship_target(sheet_part, target) + .map(|normalized| (relationship_id, normalized)) + }) + }) + .map(|entry| { + let (_, pivot_path) = entry?; + let xml = read_zip_entry(archive, &pivot_path, workbook_path)?; + parse_native_pivot_table(&xml, &pivot_path, pivot_caches) + }) + .collect() +} + +fn parse_pivot_caches( + archive: &mut ZipArchive, + workbook_path: &Path, + workbook_xml: &str, + workbook_rels: &str, +) -> Result, SpreadsheetArtifactError> { + let relationships = parse_relationships(workbook_rels)?; + let mut caches = BTreeMap::new(); + for (attributes, _) in child_tags_ns(workbook_xml, "pivotCache")? { + let Some(cache_id) = + extract_attribute(&attributes, "cacheId").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(rel_id) = + extract_attribute(&attributes, "r:id").or_else(|| extract_attribute(&attributes, "id")) + else { + continue; + }; + let Some(target) = relationships.get(&rel_id) else { + continue; + }; + let cache_path = normalize_relationship_target("xl/workbook.xml", target)?; + let cache_xml = read_zip_entry(archive, &cache_path, workbook_path)?; + let field_names = first_tag_text_ns(&cache_xml, "cacheFields") + .map(|section| { + child_tags_ns(§ion, "cacheField").map(|entries| { + entries + .into_iter() + .map(|(field_attributes, _)| extract_attribute(&field_attributes, "name")) + .collect::>() + }) + }) + .transpose()? + .unwrap_or_default(); + caches.insert( + cache_id, + crate::SpreadsheetPivotCacheDefinition { + definition_path: cache_path, + field_names, + }, + ); + } + Ok(caches) +} + +fn sheet_relationships_path(sheet_part: &str) -> Result { + let Some(parent) = Path::new(sheet_part).parent() else { + return Err(SpreadsheetArtifactError::Serialization { + message: format!("sheet part `{sheet_part}` has no parent"), + }); + }; + let Some(file_name) = Path::new(sheet_part) + .file_name() + .and_then(|value| value.to_str()) + else { + return Err(SpreadsheetArtifactError::Serialization { + message: format!("sheet part `{sheet_part}` has no file name"), + }); + }; + Ok(format!("{}/_rels/{file_name}.rels", parent.display())) +} + +fn normalize_relationship_target( + source_part: &str, + target: &str, +) -> Result { + if target.starts_with('/') { + return Ok(target.trim_start_matches('/').to_string()); + } + + let base = + Path::new(source_part) + .parent() + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: format!("source part `{source_part}` has no parent"), + })?; + let joined = base.join(target); + let mut normalized = PathBuf::new(); + for component in joined.components() { + match component { + std::path::Component::Normal(value) => normalized.push(value), + std::path::Component::ParentDir => { + normalized.pop(); + } + std::path::Component::CurDir => {} + std::path::Component::RootDir | std::path::Component::Prefix(_) => {} + } + } + Ok(normalized.to_string_lossy().into_owned()) +} + +fn parse_native_table(xml: &str) -> Result { + let table_attributes = first_tag_attributes(xml, "table").ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: "table xml missing root table tag".to_string(), + } + })?; + let id = extract_attribute(&table_attributes, "id") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: "table xml missing numeric id".to_string(), + })?; + let name = extract_attribute(&table_attributes, "name").ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: "table xml missing name".to_string(), + } + })?; + let display_name = + extract_attribute(&table_attributes, "displayName").unwrap_or_else(|| name.clone()); + let range = extract_attribute(&table_attributes, "ref").ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: "table xml missing range".to_string(), + } + })?; + let header_row_count = extract_attribute(&table_attributes, "headerRowCount") + .and_then(|value| value.parse::().ok()) + .unwrap_or(1); + let totals_row_count = extract_attribute(&table_attributes, "totalsRowCount") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + + let columns = first_tag_text(xml, "tableColumns") + .map(|section| { + child_tags(§ion, "tableColumn").map(|entries| { + entries + .into_iter() + .enumerate() + .map(|(index, (attributes, _))| crate::SpreadsheetTableColumn { + id: extract_attribute(&attributes, "id") + .and_then(|value| value.parse::().ok()) + .unwrap_or(index as u32 + 1), + name: extract_attribute(&attributes, "name") + .unwrap_or_else(|| format!("Column{}", index + 1)), + totals_row_label: extract_attribute(&attributes, "totalsRowLabel"), + totals_row_function: extract_attribute(&attributes, "totalsRowFunction"), + }) + .collect() + }) + }) + .transpose()? + .unwrap_or_default(); + + let style_attributes = first_tag_attributes(xml, "tableStyleInfo"); + Ok(crate::SpreadsheetTable { + id, + name, + display_name, + range, + header_row_count, + totals_row_count, + style_name: style_attributes + .as_ref() + .and_then(|attributes| extract_attribute(attributes, "name")), + show_first_column: style_attributes + .as_ref() + .and_then(|attributes| extract_attribute(attributes, "showFirstColumn")) + .is_some_and(|value| value == "1"), + show_last_column: style_attributes + .as_ref() + .and_then(|attributes| extract_attribute(attributes, "showLastColumn")) + .is_some_and(|value| value == "1"), + show_row_stripes: style_attributes + .as_ref() + .and_then(|attributes| extract_attribute(attributes, "showRowStripes")) + .is_some_and(|value| value == "1"), + show_column_stripes: style_attributes + .as_ref() + .and_then(|attributes| extract_attribute(attributes, "showColumnStripes")) + .is_some_and(|value| value == "1"), + columns, + filters: BTreeMap::new(), + }) +} + +fn parse_native_chart( + xml: &str, + chart_path: &str, + sheet_name: &str, + fallback_id: u32, +) -> Result { + let style_index = first_tag_attributes_ns(xml, "style") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .and_then(|value| value.parse::().ok()) + .unwrap_or(102); + let display_blanks_as = first_tag_attributes_ns(xml, "dispBlanksAs") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .unwrap_or_else(|| "gap".to_string()); + let title = + first_tag_text_ns(xml, "title").and_then(|title_xml| chart_title_from_xml(&title_xml)); + let legend_visible = first_tag_attributes_ns(xml, "delete") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .is_none_or(|value| value != "1"); + let legend_position = first_tag_attributes_ns(xml, "legendPos") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .as_deref() + .map(parse_chart_legend_position) + .unwrap_or(crate::SpreadsheetChartLegendPosition::Bottom); + let legend_overlay = first_tag_attributes_ns(xml, "overlay") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .is_some_and(|value| value == "1"); + + let (chart_type, chart_section) = chart_section(xml)?; + let series = child_tags_ns(&chart_section, "ser")? + .into_iter() + .enumerate() + .filter_map(|(index, (_, series_xml))| { + parse_native_chart_series(&series_xml, sheet_name, index as u32 + 1).transpose() + }) + .collect::, _>>()?; + let source_range = infer_chart_source_range(&series, sheet_name); + + Ok(crate::SpreadsheetChart { + id: chart_id_from_path(chart_path).unwrap_or(fallback_id), + chart_type, + source_sheet_name: source_range.as_ref().map(|_| sheet_name.to_string()), + source_range, + title, + style_index, + display_blanks_as, + legend: crate::SpreadsheetChartLegend { + visible: legend_visible, + position: legend_position, + overlay: legend_overlay, + }, + category_axis: crate::SpreadsheetChartAxis { + linked_number_format: axis_linked_number_format(xml, "catAx"), + }, + value_axis: crate::SpreadsheetChartAxis { + linked_number_format: axis_linked_number_format(xml, "valAx"), + }, + series, + }) +} + +fn parse_native_chart_series( + xml: &str, + default_sheet_name: &str, + fallback_id: u32, +) -> Result, SpreadsheetArtifactError> { + let category_formula = series_formula(xml, "cat"); + let value_formula = series_formula(xml, "val"); + let Some(value_formula) = value_formula else { + return Ok(None); + }; + let (value_sheet_name, value_range) = + parse_formula_reference(&value_formula, Some(default_sheet_name))?; + let (category_sheet_name, category_range) = if let Some(category_formula) = category_formula { + parse_formula_reference(&category_formula, Some(default_sheet_name))? + } else { + ( + Some(default_sheet_name.to_string()), + CellRange::from_start_end( + CellAddress { column: 1, row: 1 }, + CellAddress { column: 1, row: 1 }, + ) + .to_a1(), + ) + }; + + Ok(Some(crate::SpreadsheetChartSeries { + id: first_tag_attributes_ns(xml, "idx") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .and_then(|value| value.parse::().ok()) + .unwrap_or(fallback_id), + name: chart_series_name(xml), + category_sheet_name, + category_range, + value_sheet_name, + value_range, + })) +} + +fn parse_native_pivot_table( + xml: &str, + pivot_path: &str, + pivot_caches: &BTreeMap, +) -> Result { + let attributes = first_tag_attributes_ns(xml, "pivotTableDefinition").ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: "pivot table definition missing root tag".to_string(), + } + })?; + let name = extract_attribute(&attributes, "name").ok_or_else(|| { + SpreadsheetArtifactError::Serialization { + message: "pivot table definition missing name".to_string(), + } + })?; + let cache_id = extract_attribute(&attributes, "cacheId") + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| SpreadsheetArtifactError::Serialization { + message: "pivot table definition missing cacheId".to_string(), + })?; + let cache_names = pivot_caches + .get(&cache_id) + .map(|cache| cache.field_names.clone()) + .unwrap_or_default(); + + let pivot_fields = first_tag_text_ns(xml, "pivotFields") + .map(|section| { + child_tags_ns(§ion, "pivotField").map(|entries| { + entries + .into_iter() + .enumerate() + .map( + |(index, (field_attributes, field_xml))| crate::SpreadsheetPivotField { + index: index as u32, + name: cache_names.get(index).cloned().flatten(), + axis: extract_attribute(&field_attributes, "axis"), + items: first_tag_text_ns(&field_xml, "items") + .map(|items_xml| { + child_tags_ns(&items_xml, "item").map(|items| { + items + .into_iter() + .map(|(item_attributes, _)| { + crate::SpreadsheetPivotFieldItem { + item_type: extract_attribute( + &item_attributes, + "t", + ), + index: extract_attribute(&item_attributes, "x") + .and_then(|value| { + value.parse::().ok() + }), + hidden: extract_attribute( + &item_attributes, + "h", + ) + .is_some_and(|value| value == "1"), + } + }) + .collect() + }) + }) + .transpose() + .unwrap_or_default() + .unwrap_or_default(), + }, + ) + .collect() + }) + }) + .transpose()? + .unwrap_or_default(); + + Ok(crate::SpreadsheetPivotTable { + name, + cache_id, + address: first_tag_attributes_ns(xml, "location") + .and_then(|location_attributes| extract_attribute(&location_attributes, "ref")), + row_fields: field_references_from_section( + first_tag_text_ns(xml, "rowFields").as_deref(), + "field", + "x", + &cache_names, + )?, + column_fields: field_references_from_section( + first_tag_text_ns(xml, "colFields").as_deref(), + "field", + "x", + &cache_names, + )?, + page_fields: page_fields_from_section( + first_tag_text_ns(xml, "pageFields").as_deref(), + &cache_names, + )?, + data_fields: data_fields_from_section( + first_tag_text_ns(xml, "dataFields").as_deref(), + &cache_names, + )?, + filters: pivot_filters_from_section( + first_tag_text_ns(xml, "filters").as_deref(), + &cache_names, + )?, + pivot_fields, + style_name: first_tag_attributes_ns(xml, "pivotTableStyleInfo") + .and_then(|style_attributes| extract_attribute(&style_attributes, "name")), + part_path: Some(pivot_path.to_string()), + }) +} + +fn chart_section( + xml: &str, +) -> Result<(crate::SpreadsheetChartType, String), SpreadsheetArtifactError> { + for (tag, chart_type) in [ + ("areaChart", crate::SpreadsheetChartType::Area), + ("barChart", crate::SpreadsheetChartType::Bar), + ("doughnutChart", crate::SpreadsheetChartType::Doughnut), + ("lineChart", crate::SpreadsheetChartType::Line), + ("pieChart", crate::SpreadsheetChartType::Pie), + ] { + if let Some(section) = first_tag_text_ns(xml, tag) { + return Ok((chart_type, section)); + } + } + Err(SpreadsheetArtifactError::Serialization { + message: "chart xml did not contain a supported chart type".to_string(), + }) +} + +fn chart_series_name(xml: &str) -> Option { + let tx = first_tag_text_ns(xml, "tx")?; + first_tag_text_ns(&tx, "v") + .map(|value| xml_unescape(&value)) + .or_else(|| { + first_tag_text_ns(&tx, "f").map(|formula| { + parse_formula_reference(&formula, None) + .map(|(_, range)| range) + .unwrap_or_else(|_| formula) + }) + }) + .or_else(|| { + all_text_nodes_any_ns(&tx) + .ok() + .filter(|value| !value.is_empty()) + }) +} + +fn series_formula(xml: &str, axis_tag: &str) -> Option { + let axis_xml = first_tag_text_ns(xml, axis_tag)?; + ["strRef", "numRef", "multiLvlStrRef"] + .into_iter() + .find_map(|ref_tag| first_tag_text_ns(&axis_xml, ref_tag)) + .and_then(|reference_xml| first_tag_text_ns(&reference_xml, "f")) +} + +fn chart_title_from_xml(xml: &str) -> Option { + first_tag_text_ns(xml, "v") + .map(|value| xml_unescape(&value)) + .or_else(|| { + all_text_nodes_any_ns(xml) + .ok() + .filter(|value| !value.is_empty()) + }) +} + +fn parse_chart_legend_position(value: &str) -> crate::SpreadsheetChartLegendPosition { + match value { + "t" => crate::SpreadsheetChartLegendPosition::Top, + "l" => crate::SpreadsheetChartLegendPosition::Left, + "r" => crate::SpreadsheetChartLegendPosition::Right, + _ => crate::SpreadsheetChartLegendPosition::Bottom, + } +} + +fn axis_linked_number_format(xml: &str, axis_tag: &str) -> bool { + first_tag_text_ns(xml, axis_tag) + .and_then(|axis_xml| first_tag_attributes_ns(&axis_xml, "numFmt")) + .and_then(|attributes| extract_attribute(&attributes, "sourceLinked")) + .is_none_or(|value| value != "0") +} + +fn chart_id_from_path(path: &str) -> Option { + Path::new(path) + .file_stem() + .and_then(|value| value.to_str()) + .and_then(|value| value.trim_start_matches("chart").parse::().ok()) +} + +fn infer_chart_source_range( + series: &[crate::SpreadsheetChartSeries], + sheet_name: &str, +) -> Option { + let first = series.first()?; + let category_sheet_name = first.category_sheet_name.as_deref()?; + if category_sheet_name != sheet_name { + return None; + } + let category_range = CellRange::parse(&first.category_range).ok()?; + let mut columns = vec![category_range.start.column]; + let mut row_start = category_range.start.row; + let mut row_end = category_range.end.row; + + for entry in series { + if entry.category_sheet_name.as_deref() != Some(sheet_name) + || entry.value_sheet_name.as_deref() != Some(sheet_name) + { + return None; + } + let category = CellRange::parse(&entry.category_range).ok()?; + let value = CellRange::parse(&entry.value_range).ok()?; + if category != category_range + || !value.is_single_column() + || value.start.row != row_start + || value.end.row != row_end + { + return None; + } + columns.push(value.start.column); + row_start = row_start.min(value.start.row); + row_end = row_end.max(value.end.row); + } + + columns.sort_unstable(); + columns.dedup(); + if columns.len() != series.len() + 1 { + return None; + } + let start = *columns.first()?; + let end = *columns.last()?; + if columns != (start..=end).collect::>() { + return None; + } + Some( + CellRange::from_start_end( + CellAddress { + column: start, + row: row_start, + }, + CellAddress { + column: end, + row: row_end, + }, + ) + .to_a1(), + ) +} + +fn parse_formula_reference( + formula: &str, + default_sheet_name: Option<&str>, +) -> Result<(Option, String), SpreadsheetArtifactError> { + let trimmed = formula.trim().trim_start_matches('='); + if let Some((sheet_name, range)) = trimmed.split_once('!') { + return Ok(( + Some(sheet_name.trim_matches('\'').replace("''", "'")), + normalize_a1_reference(range), + )); + } + Ok(( + default_sheet_name.map(str::to_string), + normalize_a1_reference(trimmed), + )) +} + +fn normalize_a1_reference(reference: &str) -> String { + reference.replace('$', "") +} + +fn field_references_from_section( + section: Option<&str>, + child_tag: &str, + field_attr: &str, + cache_names: &[Option], +) -> Result, SpreadsheetArtifactError> { + let Some(section) = section else { + return Ok(Vec::new()); + }; + Ok(child_tags_ns(section, child_tag)? + .into_iter() + .filter_map(|(attributes, _)| { + extract_attribute(&attributes, field_attr) + .and_then(|value| value.parse::().ok()) + .map(|field_index| crate::SpreadsheetPivotFieldReference { + field_index, + field_name: cache_names.get(field_index as usize).cloned().flatten(), + }) + }) + .collect::>()) +} + +fn page_fields_from_section( + section: Option<&str>, + cache_names: &[Option], +) -> Result, SpreadsheetArtifactError> { + let Some(section) = section else { + return Ok(Vec::new()); + }; + Ok(child_tags_ns(section, "pageField")? + .into_iter() + .filter_map(|(attributes, _)| { + extract_attribute(&attributes, "fld") + .and_then(|value| value.parse::().ok()) + .map(|field_index| crate::SpreadsheetPivotPageField { + field_index, + field_name: cache_names.get(field_index as usize).cloned().flatten(), + selected_item: extract_attribute(&attributes, "item") + .and_then(|value| value.parse::().ok()), + }) + }) + .collect::>()) +} + +fn data_fields_from_section( + section: Option<&str>, + cache_names: &[Option], +) -> Result, SpreadsheetArtifactError> { + let Some(section) = section else { + return Ok(Vec::new()); + }; + Ok(child_tags_ns(section, "dataField")? + .into_iter() + .filter_map(|(attributes, _)| { + extract_attribute(&attributes, "fld") + .and_then(|value| value.parse::().ok()) + .map(|field_index| crate::SpreadsheetPivotDataField { + field_index, + field_name: cache_names.get(field_index as usize).cloned().flatten(), + name: extract_attribute(&attributes, "name"), + subtotal: extract_attribute(&attributes, "subtotal"), + }) + }) + .collect::>()) +} + +fn pivot_filters_from_section( + section: Option<&str>, + cache_names: &[Option], +) -> Result, SpreadsheetArtifactError> { + let Some(section) = section else { + return Ok(Vec::new()); + }; + Ok(child_tags_ns(section, "filter")? + .into_iter() + .map(|(attributes, _)| { + let field_index = + extract_attribute(&attributes, "fld").and_then(|value| value.parse::().ok()); + crate::SpreadsheetPivotFilter { + field_index, + field_name: field_index + .and_then(|index| cache_names.get(index as usize).cloned().flatten()), + filter_type: extract_attribute(&attributes, "type"), + } + }) + .collect::>()) +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +struct CodexSpreadsheetMetadata { + #[serde(default)] + sheets: BTreeMap, + #[serde(default)] + differential_formats: BTreeMap, +} + +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +struct CodexSheetMetadata { + #[serde(default)] + charts: Vec, + #[serde(default)] + tables: Vec, + #[serde(default)] + conditional_formats: Vec, + #[serde(default)] + pivot_tables: Vec, +} + +#[derive(Default)] +struct ParsedStyles { + text_styles: BTreeMap, + fills: BTreeMap, + borders: BTreeMap, + number_formats: BTreeMap, + cell_formats: BTreeMap, + differential_formats: BTreeMap, + dxf_id_map: BTreeMap, +} + +#[derive(Default)] +struct ParsedCellFormat { + font_index: Option, + fill_index: Option, + border_index: Option, + number_format_index: Option, + alignment: Option, + wrap_text: Option, +} + +type ParsedDifferentialFormats = ( + BTreeMap, + BTreeMap, +); + +fn parse_styles(xml: &str) -> Result { + let custom_number_formats = parse_custom_number_formats(xml)?; + let fonts = parse_fonts(xml)?; + let fills = parse_fills(xml)?; + let borders = parse_borders(xml)?; + let xfs = parse_cell_formats(xml)?; + + let mut parsed = ParsedStyles::default(); + let mut font_id_map = BTreeMap::new(); + let mut fill_id_map = BTreeMap::new(); + let mut border_id_map = BTreeMap::new(); + let mut number_format_id_map = BTreeMap::new(); + + for (index, font) in fonts.into_iter().enumerate() { + if index == 0 || font == SpreadsheetTextStyle::default() { + continue; + } + let internal_id = next_style_component_id(&parsed.text_styles); + parsed.text_styles.insert(internal_id, font); + font_id_map.insert(index as u32, internal_id); + } + + for (index, fill) in fills.into_iter().enumerate() { + if index < 2 || fill == SpreadsheetFill::default() { + continue; + } + let internal_id = next_style_component_id(&parsed.fills); + parsed.fills.insert(internal_id, fill); + fill_id_map.insert(index as u32, internal_id); + } + + for (index, border) in borders.into_iter().enumerate() { + if index == 0 || border == SpreadsheetBorder::default() { + continue; + } + let internal_id = next_style_component_id(&parsed.borders); + parsed.borders.insert(internal_id, border); + border_id_map.insert(index as u32, internal_id); + } + + for (style_index, xf) in xfs.into_iter().enumerate() { + let number_format_id = xf.number_format_index.and_then(|num_fmt_id| { + resolve_number_format_id( + num_fmt_id, + &custom_number_formats, + &mut parsed.number_formats, + &mut number_format_id_map, + ) + }); + + let format = SpreadsheetCellFormat { + text_style_id: xf + .font_index + .and_then(|value| font_id_map.get(&value).copied()), + fill_id: xf + .fill_index + .and_then(|value| fill_id_map.get(&value).copied()), + border_id: xf + .border_index + .and_then(|value| border_id_map.get(&value).copied()), + alignment: xf.alignment, + number_format_id, + wrap_text: xf.wrap_text, + base_cell_style_format_id: None, + }; + + if style_index != 0 || format != SpreadsheetCellFormat::default() { + parsed.cell_formats.insert(style_index as u32, format); + } + } + + let (differential_formats, dxf_id_map) = parse_differential_formats( + xml, + &mut parsed.text_styles, + &mut parsed.fills, + &mut parsed.borders, + &mut parsed.number_formats, + &custom_number_formats, + &mut number_format_id_map, + )?; + parsed.differential_formats = differential_formats; + parsed.dxf_id_map = dxf_id_map; + + Ok(parsed) +} + +fn parse_custom_number_formats( + xml: &str, +) -> Result, SpreadsheetArtifactError> { + let Some(section) = first_tag_text(xml, "numFmts") else { + return Ok(BTreeMap::new()); + }; + + let mut number_formats = BTreeMap::new(); + for (attributes, _) in child_tags(§ion, "numFmt")? { + let Some(num_fmt_id) = + extract_attribute(&attributes, "numFmtId").and_then(|value| value.parse::().ok()) + else { + continue; + }; + let Some(format_code) = extract_attribute(&attributes, "formatCode") else { + continue; + }; + number_formats.insert(num_fmt_id, format_code); + } + Ok(number_formats) +} + +fn parse_fonts(xml: &str) -> Result, SpreadsheetArtifactError> { + let Some(section) = first_tag_text(xml, "fonts") else { + return Ok(Vec::new()); + }; + + child_tags(§ion, "font")? + .into_iter() + .map(|(_, body)| Ok(parse_font_body(&body))) + .collect() +} + +fn parse_fills(xml: &str) -> Result, SpreadsheetArtifactError> { + let Some(section) = first_tag_text(xml, "fills") else { + return Ok(Vec::new()); + }; + + child_tags(§ion, "fill")? + .into_iter() + .map(|(_, body)| parse_fill_body(&body)) + .collect() +} + +fn parse_borders(xml: &str) -> Result, SpreadsheetArtifactError> { + let Some(section) = first_tag_text(xml, "borders") else { + return Ok(Vec::new()); + }; + + child_tags(§ion, "border")? + .into_iter() + .map(|(_, body)| Ok(parse_border_body(&body))) + .collect() +} + +fn parse_font_body(body: &str) -> SpreadsheetTextStyle { + let name = first_tag_attributes(body, "name") + .and_then(|attributes| extract_attribute(&attributes, "val")); + SpreadsheetTextStyle { + bold: first_tag_attributes(body, "b").map(|_| true), + italic: first_tag_attributes(body, "i").map(|_| true), + underline: first_tag_attributes(body, "u").map(|_| true), + font_size: first_tag_attributes(body, "sz") + .and_then(|attributes| extract_attribute(&attributes, "val")) + .and_then(|value| value.parse::().ok()), + font_color: first_tag_attributes(body, "color") + .and_then(|attributes| parse_color_value(&attributes)), + font_family: name.clone(), + typeface: name, + font_scheme: first_tag_attributes(body, "scheme") + .and_then(|attributes| extract_attribute(&attributes, "val")), + ..Default::default() + } +} + +fn parse_fill_body(body: &str) -> Result { + let mut fill = SpreadsheetFill::default(); + if let Some(pattern_attributes) = first_tag_attributes(body, "patternFill") { + fill.pattern_type = extract_attribute(&pattern_attributes, "patternType"); + fill.pattern_foreground_color = first_tag_attributes(body, "fgColor") + .and_then(|attributes| parse_color_value(&attributes)); + fill.pattern_background_color = first_tag_attributes(body, "bgColor") + .and_then(|attributes| parse_color_value(&attributes)); + fill.solid_fill_color = fill.pattern_foreground_color.clone(); + } + if let Some(gradient_attributes) = first_tag_attributes(body, "gradientFill") { + fill.gradient_fill_type = Some( + extract_attribute(&gradient_attributes, "type").unwrap_or_else(|| "linear".to_string()), + ); + fill.gradient_kind = fill.gradient_fill_type.clone(); + fill.angle = extract_attribute(&gradient_attributes, "degree") + .and_then(|value| value.parse::().ok()); + let rectangle = SpreadsheetFillRectangle { + left: extract_attribute(&gradient_attributes, "left") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0), + right: extract_attribute(&gradient_attributes, "right") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0), + top: extract_attribute(&gradient_attributes, "top") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0), + bottom: extract_attribute(&gradient_attributes, "bottom") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0.0), + }; + if rectangle.left != 0.0 + || rectangle.right != 0.0 + || rectangle.top != 0.0 + || rectangle.bottom != 0.0 + { + fill.fill_rectangle = Some(rectangle); + } + fill.gradient_stops = child_tags(body, "stop")? + .into_iter() + .filter_map(|(attributes, stop_body)| { + let position = extract_attribute(&attributes, "position") + .and_then(|value| value.parse::().ok())?; + let color = first_tag_attributes(&stop_body, "color") + .and_then(|stop_attributes| parse_color_value(&stop_attributes))?; + Some(SpreadsheetGradientStop { position, color }) + }) + .collect(); + } + Ok(fill) +} + +fn parse_border_body(body: &str) -> SpreadsheetBorder { + SpreadsheetBorder { + top: parse_border_line(body, "top"), + right: parse_border_line(body, "right"), + bottom: parse_border_line(body, "bottom"), + left: parse_border_line(body, "left"), + } +} + +fn parse_border_line(body: &str, edge: &str) -> Option { + let mut matches = child_tags(body, edge).ok()?; + let (attributes, edge_body) = matches.pop()?; + let style = extract_attribute(&attributes, "style"); + let color = first_tag_attributes(&edge_body, "color") + .and_then(|color_attributes| parse_color_value(&color_attributes)); + if style.is_none() && color.is_none() { + None + } else { + Some(SpreadsheetBorderLine { style, color }) + } +} + +fn parse_alignment_attributes(attributes: &str) -> (Option, Option) { + let alignment = SpreadsheetAlignment { + horizontal: extract_attribute(attributes, "horizontal"), + vertical: extract_attribute(attributes, "vertical"), + }; + let wrap_text = + extract_attribute(attributes, "wrapText").and_then(|value| parse_xlsx_bool(&value)); + ( + (alignment != SpreadsheetAlignment::default()).then_some(alignment), + wrap_text, + ) +} + +fn parse_xlsx_bool(value: &str) -> Option { + match value { + "1" | "true" | "TRUE" => Some(true), + "0" | "false" | "FALSE" => Some(false), + _ => None, + } +} + +fn parse_cell_formats(xml: &str) -> Result, SpreadsheetArtifactError> { + let Some(section) = first_tag_text(xml, "cellXfs") else { + return Ok(Vec::new()); + }; + + child_tags(§ion, "xf")? + .into_iter() + .map(|(attributes, body)| { + let (alignment, wrap_text) = first_tag_attributes(&body, "alignment") + .map(|attributes| parse_alignment_attributes(&attributes)) + .unwrap_or_default(); + + Ok(ParsedCellFormat { + font_index: extract_attribute(&attributes, "fontId") + .and_then(|value| value.parse::().ok()), + fill_index: extract_attribute(&attributes, "fillId") + .and_then(|value| value.parse::().ok()), + border_index: extract_attribute(&attributes, "borderId") + .and_then(|value| value.parse::().ok()), + number_format_index: extract_attribute(&attributes, "numFmtId") + .and_then(|value| value.parse::().ok()), + alignment, + wrap_text, + }) + }) + .collect() +} + +fn parse_differential_formats( + xml: &str, + text_styles: &mut BTreeMap, + fills: &mut BTreeMap, + borders: &mut BTreeMap, + number_formats: &mut BTreeMap, + custom_number_formats: &BTreeMap, + number_format_id_map: &mut BTreeMap, +) -> Result { + let Some(section) = first_tag_text(xml, "dxfs") else { + return Ok((BTreeMap::new(), BTreeMap::new())); + }; + + let mut differential_formats = BTreeMap::new(); + let mut dxf_id_map = BTreeMap::new(); + for (xlsx_dxf_index, (_, body)) in child_tags(§ion, "dxf")?.into_iter().enumerate() { + let text_style_id = first_tag_text(&body, "font") + .and_then(|font_body| insert_style_component(text_styles, parse_font_body(&font_body))); + let fill_id = first_tag_text(&body, "fill") + .map(|fill_body| parse_fill_body(&fill_body)) + .transpose()? + .and_then(|fill| insert_style_component(fills, fill)); + let border_id = first_tag_text(&body, "border") + .map(|border_body| parse_border_body(&border_body)) + .and_then(|border| insert_style_component(borders, border)); + let number_format_id = first_tag_attributes(&body, "numFmt") + .and_then(|attributes| extract_attribute(&attributes, "numFmtId")) + .and_then(|value| value.parse::().ok()) + .and_then(|num_fmt_id| { + resolve_number_format_id( + num_fmt_id, + custom_number_formats, + number_formats, + number_format_id_map, + ) + }); + let (alignment, wrap_text) = first_tag_attributes(&body, "alignment") + .map(|attributes| parse_alignment_attributes(&attributes)) + .unwrap_or_default(); + let format = crate::SpreadsheetDifferentialFormat { + text_style_id, + fill_id, + border_id, + alignment, + number_format_id, + wrap_text, + }; + if format == crate::SpreadsheetDifferentialFormat::default() { + continue; + } + let internal_id = next_style_component_id(&differential_formats); + differential_formats.insert(internal_id, format); + dxf_id_map.insert(xlsx_dxf_index as u32, internal_id); + } + Ok((differential_formats, dxf_id_map)) +} + +fn insert_style_component(map: &mut BTreeMap, value: T) -> Option +where + T: Default + PartialEq, +{ + if value == T::default() { + return None; + } + let internal_id = next_style_component_id(map); + map.insert(internal_id, value); + Some(internal_id) +} + +fn resolve_number_format_id( + num_fmt_id: u32, + custom_number_formats: &BTreeMap, + number_formats: &mut BTreeMap, + number_format_id_map: &mut BTreeMap, +) -> Option { + if num_fmt_id == 0 { + return None; + } + if let Some(existing) = number_format_id_map.get(&num_fmt_id).copied() { + return Some(existing); + } + let internal_id = next_style_component_id(number_formats); + number_formats.insert( + internal_id, + SpreadsheetNumberFormat { + format_id: Some(num_fmt_id), + format_code: custom_number_formats + .get(&num_fmt_id) + .cloned() + .or_else(|| builtin_number_format_code_for_xlsx(num_fmt_id)), + }, + ); + number_format_id_map.insert(num_fmt_id, internal_id); + Some(internal_id) +} + +fn next_style_component_id(map: &BTreeMap) -> u32 { + map.last_key_value().map(|(id, _)| id + 1).unwrap_or(1) +} + +fn content_types_xml(sheet_count: usize, include_custom_metadata: bool) -> String { let mut overrides = String::new(); for index in 1..=sheet_count { overrides.push_str(&format!( r#""# )); } + if include_custom_metadata { + overrides.push_str( + r#""#, + ); + } format!( "{}{}{}{}{}{}{}{}{}{}", r#""#, @@ -504,6 +1930,63 @@ fn content_types_xml(sheet_count: usize) -> String { ) } +fn artifact_has_custom_metadata(artifact: &SpreadsheetArtifact) -> bool { + artifact.sheets.iter().any(|sheet| { + !sheet.charts.is_empty() + || !sheet.tables.is_empty() + || !sheet.conditional_formats.is_empty() + || !sheet.pivot_tables.is_empty() + }) +} + +fn codex_metadata_xml(artifact: &SpreadsheetArtifact) -> Result { + let metadata = CodexSpreadsheetMetadata { + differential_formats: artifact.differential_formats.clone(), + sheets: artifact + .sheets + .iter() + .filter_map(|sheet| { + let sheet_metadata = CodexSheetMetadata { + charts: sheet.charts.clone(), + tables: sheet.tables.clone(), + conditional_formats: sheet.conditional_formats.clone(), + pivot_tables: sheet.pivot_tables.clone(), + }; + (!sheet_metadata.charts.is_empty() + || !sheet_metadata.tables.is_empty() + || !sheet_metadata.conditional_formats.is_empty() + || !sheet_metadata.pivot_tables.is_empty()) + .then_some((sheet.name.clone(), sheet_metadata)) + }) + .collect(), + }; + let json = serde_json::to_string(&metadata).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + })?; + Ok(format!( + concat!( + r#""#, + r#""#, + r#"{}"#, + r#""# + ), + xml_escape(&json) + )) +} + +fn parse_codex_metadata(xml: &str) -> Result { + let Some(json) = first_tag_text(xml, "json") else { + return Ok(CodexSpreadsheetMetadata::default()); + }; + serde_json::from_str(&xml_unescape(&json)).map_err(|error| { + SpreadsheetArtifactError::Serialization { + message: error.to_string(), + } + }) +} + fn root_relationships_xml() -> &'static str { concat!( r#""#, @@ -626,26 +2109,141 @@ fn styles_xml(artifact: &SpreadsheetArtifact) -> String { .sheets .iter() .flat_map(|sheet| sheet.cells.values().map(|cell| cell.style_index)) + .chain(artifact.cell_formats.keys().copied()) .max() .unwrap_or(0); + let font_ids = artifact.text_styles.keys().copied().collect::>(); + let fill_ids = artifact.fills.keys().copied().collect::>(); + let border_ids = artifact.borders.keys().copied().collect::>(); + let number_format_ids = artifact.number_formats.keys().copied().collect::>(); + let differential_format_ids = artifact + .differential_formats + .keys() + .copied() + .collect::>(); + + let font_indices = font_ids + .iter() + .enumerate() + .map(|(index, id)| (*id, index as u32 + 1)) + .collect::>(); + let fill_indices = fill_ids + .iter() + .enumerate() + .map(|(index, id)| (*id, index as u32 + 2)) + .collect::>(); + let border_indices = border_ids + .iter() + .enumerate() + .map(|(index, id)| (*id, index as u32 + 1)) + .collect::>(); + let number_format_indices = assign_number_format_ids(artifact, &number_format_ids); + + let fonts = std::iter::once("".to_string()) + .chain( + font_ids + .iter() + .filter_map(|id| artifact.text_styles.get(id).map(spreadsheet_font_xml)), + ) + .collect::>() + .join(""); + let fills = [ + r#""#.to_string(), + r#""#.to_string(), + ] + .into_iter() + .chain( + fill_ids + .iter() + .filter_map(|id| artifact.fills.get(id).map(spreadsheet_fill_xml)), + ) + .collect::>() + .join(""); + let borders = std::iter::once("".to_string()) + .chain( + border_ids + .iter() + .filter_map(|id| artifact.borders.get(id).map(spreadsheet_border_xml)), + ) + .collect::>() + .join(""); + let number_formats = number_format_ids + .iter() + .filter_map(|id| { + artifact.number_formats.get(id).and_then(|format| { + let excel_id = number_format_indices.get(id).copied()?; + format + .format_code + .as_ref() + .filter(|_| excel_id >= 164) + .map(|format_code| { + format!( + r#""#, + xml_escape(format_code) + ) + }) + }) + }) + .collect::>(); + let num_fmts_xml = if number_formats.is_empty() { + String::new() + } else { + format!( + r#"{}"#, + number_formats.len(), + number_formats.join("") + ) + }; + let differential_formats = differential_format_ids + .iter() + .filter_map(|id| { + artifact.differential_formats.get(id).map(|format| { + spreadsheet_differential_format_xml(artifact, format, &number_format_indices) + }) + }) + .collect::>() + .join(""); let cell_xfs = (0..=max_style_index) - .map(|_| r#""#) + .map(|style_index| { + let format = artifact + .resolve_cell_format(style_index) + .or_else(|| artifact.get_cell_format(style_index).cloned()) + .unwrap_or_default(); + spreadsheet_cell_format_xml( + &format, + &font_indices, + &fill_indices, + &border_indices, + &number_format_indices, + ) + }) .collect::>() .join(""); format!( concat!( r#""#, r#""#, - r#""#, - r#""#, - r#""#, + r#"{}"#, + r#"{}"#, + r#"{}"#, + r#"{}"#, r#""#, r#"{}"#, + r#"{}"#, r#""#, r#""# ), + num_fmts_xml, + font_ids.len() + 1, + fonts, + fill_ids.len() + 2, + fills, + border_ids.len() + 1, + borders, max_style_index + 1, cell_xfs, + differential_format_ids.len(), + differential_formats, ) } @@ -803,12 +2401,79 @@ fn first_tag_attributes(xml: &str, tag: &str) -> Option { captures.get(1).map(|value| value.as_str().to_string()) } +fn first_tag_attributes_ns(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!(r#"<(?:[A-Za-z0-9_]+:)?{tag}\b([^>]*)/?>"#)).ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + +fn child_tags(xml: &str, tag: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(&format!( + r#"(?s)<{tag}\b([^>]*)/>|<{tag}\b([^>]*)>(.*?)"# + )) + .map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + })?; + Ok(regex + .captures_iter(xml) + .map(|captures| { + let attributes = captures + .get(1) + .or_else(|| captures.get(2)) + .map(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let body = captures + .get(3) + .map(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + (attributes, body) + }) + .collect()) +} + +fn child_tags_ns(xml: &str, tag: &str) -> Result, SpreadsheetArtifactError> { + let regex = Regex::new(&format!( + r#"(?s)<(?:[A-Za-z0-9_]+:)?{tag}\b([^>]*)/>|<(?:[A-Za-z0-9_]+:)?{tag}\b([^>]*)>(.*?)"# + )) + .map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + })?; + Ok(regex + .captures_iter(xml) + .map(|captures| { + let attributes = captures + .get(1) + .or_else(|| captures.get(2)) + .map(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let body = captures + .get(3) + .map(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + (attributes, body) + }) + .collect()) +} + fn first_tag_text(xml: &str, tag: &str) -> Option { let regex = Regex::new(&format!(r#"(?s)<{tag}\b[^>]*>(.*?)"#)).ok()?; let captures = regex.captures(xml)?; captures.get(1).map(|value| value.as_str().to_string()) } +fn first_tag_text_ns(xml: &str, tag: &str) -> Option { + let regex = Regex::new(&format!( + r#"(?s)<(?:[A-Za-z0-9_]+:)?{tag}\b[^>]*>(.*?)"# + )) + .ok()?; + let captures = regex.captures(xml)?; + captures.get(1).map(|value| value.as_str().to_string()) +} + fn extract_workbook_title(xml: &str) -> String { let Ok(regex) = Regex::new(r#"(?s)<(?:[A-Za-z0-9_]+:)?title\b[^>]*>(.*?)"#) @@ -834,6 +2499,18 @@ fn all_text_nodes(xml: &str) -> Result { .join("")) } +fn all_text_nodes_any_ns(xml: &str) -> Result { + let regex = Regex::new(r#"(?s)<(?:[A-Za-z0-9_]+:)?t\b[^>]*>(.*?)"#) + .map_err(|error| SpreadsheetArtifactError::Serialization { + message: error.to_string(), + })?; + Ok(regex + .captures_iter(xml) + .filter_map(|captures| captures.get(1).map(|value| xml_unescape(value.as_str()))) + .collect::>() + .join("")) +} + fn extract_attribute(attributes: &str, name: &str) -> Option { let pattern = format!(r#"{name}="([^"]*)""#); let regex = Regex::new(&pattern).ok()?; @@ -841,6 +2518,320 @@ fn extract_attribute(attributes: &str, name: &str) -> Option { captures.get(1).map(|value| xml_unescape(value.as_str())) } +fn parse_color_value(attributes: &str) -> Option { + let rgb = extract_attribute(attributes, "rgb")?; + let normalized = rgb.trim(); + if normalized.len() == 8 { + Some(format!("#{}", normalized[2..].to_ascii_uppercase())) + } else if normalized.len() == 6 { + Some(format!("#{}", normalized.to_ascii_uppercase())) + } else { + None + } +} + +fn color_xml(tag: &str, color: &str) -> Option { + let rgb = xlsx_rgb(color)?; + Some(format!(r#"<{tag} rgb="{rgb}"/>"#)) +} + +fn xlsx_rgb(color: &str) -> Option { + let hex = color.trim().trim_start_matches('#'); + if hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + Some(format!("FF{}", hex.to_ascii_uppercase())) + } else if hex.len() == 8 && hex.chars().all(|ch| ch.is_ascii_hexdigit()) { + Some(hex.to_ascii_uppercase()) + } else { + None + } +} + +fn builtin_number_format_code_for_xlsx(format_id: u32) -> Option { + match format_id { + 0 => Some("General".to_string()), + 1 => Some("0".to_string()), + 2 => Some("0.00".to_string()), + 3 => Some("#,##0".to_string()), + 4 => Some("#,##0.00".to_string()), + 9 => Some("0%".to_string()), + 10 => Some("0.00%".to_string()), + _ => None, + } +} + +fn spreadsheet_font_xml(style: &SpreadsheetTextStyle) -> String { + let mut parts = Vec::new(); + if style.bold == Some(true) { + parts.push("".to_string()); + } + if style.italic == Some(true) { + parts.push("".to_string()); + } + if style.underline == Some(true) { + parts.push("".to_string()); + } + if let Some(font_size) = style.font_size { + parts.push(format!(r#""#)); + } + if let Some(color) = style + .font_color + .as_deref() + .and_then(|value| color_xml("color", value)) + { + parts.push(color); + } + let font_name = style + .typeface + .as_deref() + .or(style.font_family.as_deref()) + .or_else(|| { + style + .font_face + .as_ref() + .and_then(|face| face.typeface.as_deref()) + }) + .or_else(|| { + style + .font_face + .as_ref() + .and_then(|face| face.font_family.as_deref()) + }); + if let Some(font_name) = font_name { + parts.push(format!(r#""#, xml_escape(font_name))); + } + if let Some(font_scheme) = style.font_scheme.as_deref().or_else(|| { + style + .font_face + .as_ref() + .and_then(|face| face.font_scheme.as_deref()) + }) { + parts.push(format!(r#""#, xml_escape(font_scheme))); + } + format!("{}", parts.join("")) +} + +fn spreadsheet_fill_xml(fill: &SpreadsheetFill) -> String { + if !fill.gradient_stops.is_empty() + || fill.gradient_fill_type.is_some() + || fill.gradient_kind.is_some() + { + let gradient_type = fill + .gradient_kind + .as_deref() + .or(fill.gradient_fill_type.as_deref()) + .unwrap_or("linear"); + let gradient_attributes = if gradient_type.eq_ignore_ascii_case("path") { + let rectangle = fill.fill_rectangle.as_ref(); + format!( + r#" type="path" left="{}" right="{}" top="{}" bottom="{}""#, + rectangle.map(|entry| entry.left).unwrap_or(0.0), + rectangle.map(|entry| entry.right).unwrap_or(0.0), + rectangle.map(|entry| entry.top).unwrap_or(0.0), + rectangle.map(|entry| entry.bottom).unwrap_or(0.0) + ) + } else { + fill.angle + .map(|angle| format!(r#" degree="{angle}""#)) + .unwrap_or_default() + }; + let stops = fill + .gradient_stops + .iter() + .filter_map(|stop| { + color_xml("color", &stop.color) + .map(|color| format!(r#"{color}"#, stop.position)) + }) + .collect::>() + .join(""); + return format!( + r#"{stops}"# + ); + } + + let pattern_type = fill.pattern_type.as_deref().unwrap_or_else(|| { + if fill.solid_fill_color.is_some() { + "solid" + } else { + "none" + } + }); + let fg_color = fill + .pattern_foreground_color + .as_deref() + .or(fill.solid_fill_color.as_deref()) + .and_then(|value| color_xml("fgColor", value)) + .unwrap_or_default(); + let bg_color = fill + .pattern_background_color + .as_deref() + .and_then(|value| color_xml("bgColor", value)) + .unwrap_or_default(); + format!( + r#"{fg_color}{bg_color}"# + ) +} + +fn spreadsheet_border_xml(border: &SpreadsheetBorder) -> String { + format!( + "{}{}{}{}", + spreadsheet_border_line_xml("left", border.left.as_ref()), + spreadsheet_border_line_xml("right", border.right.as_ref()), + spreadsheet_border_line_xml("top", border.top.as_ref()), + spreadsheet_border_line_xml("bottom", border.bottom.as_ref()), + ) +} + +fn spreadsheet_border_line_xml(edge: &str, line: Option<&SpreadsheetBorderLine>) -> String { + let Some(line) = line else { + return format!("<{edge}/>"); + }; + let style = line + .style + .as_deref() + .map(|value| format!(r#" style="{}""#, xml_escape(value))) + .unwrap_or_default(); + let color = line + .color + .as_deref() + .and_then(|value| color_xml("color", value)) + .unwrap_or_default(); + format!(r#"<{edge}{style}>{color}"#) +} + +fn spreadsheet_alignment_xml( + alignment: Option<&SpreadsheetAlignment>, + wrap_text: Option, +) -> String { + let horizontal = alignment + .and_then(|alignment| alignment.horizontal.as_deref()) + .map(|value| format!(r#" horizontal="{}""#, xml_escape(value))) + .unwrap_or_default(); + let vertical = alignment + .and_then(|alignment| alignment.vertical.as_deref()) + .map(|value| format!(r#" vertical="{}""#, xml_escape(value))) + .unwrap_or_default(); + let wrap_text = wrap_text + .map(|value| format!(r#" wrapText="{}""#, usize::from(value))) + .unwrap_or_default(); + if horizontal.is_empty() && vertical.is_empty() && wrap_text.is_empty() { + String::new() + } else { + format!("") + } +} + +fn spreadsheet_differential_format_xml( + artifact: &SpreadsheetArtifact, + format: &crate::SpreadsheetDifferentialFormat, + number_format_indices: &BTreeMap, +) -> String { + let mut parts = Vec::new(); + if let Some(style) = format + .text_style_id + .and_then(|id| artifact.text_styles.get(&id)) + { + parts.push(spreadsheet_font_xml(style)); + } + if let Some(fill) = format.fill_id.and_then(|id| artifact.fills.get(&id)) { + parts.push(spreadsheet_fill_xml(fill)); + } + if let Some(border) = format.border_id.and_then(|id| artifact.borders.get(&id)) { + parts.push(spreadsheet_border_xml(border)); + } + if let Some(number_format) = format + .number_format_id + .and_then(|id| artifact.number_formats.get(&id)) + && let Some(num_fmt_id) = format + .number_format_id + .and_then(|id| number_format_indices.get(&id).copied()) + && let Some(format_code) = number_format.format_code.clone().or_else(|| { + number_format + .format_id + .and_then(builtin_number_format_code_for_xlsx) + }) + { + parts.push(format!( + r#""#, + xml_escape(&format_code) + )); + } + let alignment_xml = spreadsheet_alignment_xml(format.alignment.as_ref(), format.wrap_text); + if !alignment_xml.is_empty() { + parts.push(alignment_xml); + } + format!("{}", parts.join("")) +} + +fn assign_number_format_ids( + artifact: &SpreadsheetArtifact, + number_format_ids: &[u32], +) -> BTreeMap { + let mut assigned = BTreeMap::new(); + let mut next_custom_id = 164; + for id in number_format_ids { + let Some(format) = artifact.number_formats.get(id) else { + continue; + }; + let excel_id = format.format_id.unwrap_or_else(|| { + while artifact + .number_formats + .values() + .any(|entry| entry.format_id == Some(next_custom_id)) + { + next_custom_id += 1; + } + let assigned_id = next_custom_id; + next_custom_id += 1; + assigned_id + }); + assigned.insert(*id, excel_id); + } + assigned +} + +fn spreadsheet_cell_format_xml( + format: &SpreadsheetCellFormat, + font_indices: &BTreeMap, + fill_indices: &BTreeMap, + border_indices: &BTreeMap, + number_format_indices: &BTreeMap, +) -> String { + let font_id = format + .text_style_id + .and_then(|id| font_indices.get(&id).copied()) + .unwrap_or(0); + let fill_id = format + .fill_id + .and_then(|id| fill_indices.get(&id).copied()) + .unwrap_or(0); + let border_id = format + .border_id + .and_then(|id| border_indices.get(&id).copied()) + .unwrap_or(0); + let num_fmt_id = format + .number_format_id + .and_then(|id| number_format_indices.get(&id).copied()) + .unwrap_or(0); + + let alignment_xml = spreadsheet_alignment_xml(format.alignment.as_ref(), format.wrap_text); + + let apply_alignment = usize::from(!alignment_xml.is_empty()); + let apply_number_format = usize::from(num_fmt_id != 0); + let apply_fill = usize::from(fill_id != 0); + let apply_border = usize::from(border_id != 0); + let apply_font = usize::from(font_id != 0); + + if alignment_xml.is_empty() { + format!( + r#""# + ) + } else { + format!( + r#"{alignment_xml}"# + ) + } +} + fn xml_escape(value: &str) -> String { value .replace('&', "&") diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index b6126840d9..31b4dcdb44 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -67,8 +67,6 @@ pub enum ResponseEvent { Completed { response_id: String, token_usage: Option, - /// Whether the client can append more items to a long-running websocket response. - can_append: bool, }, OutputTextDelta(String), ReasoningSummaryDelta { @@ -211,20 +209,12 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } -#[derive(Debug, Serialize)] -pub struct ResponseAppendWsRequest { - pub input: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_metadata: Option>, -} #[derive(Debug, Serialize)] #[serde(tag = "type")] #[allow(clippy::large_enum_variant)] pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), - #[serde(rename = "response.append")] - ResponseAppend(ResponseAppendWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index b9152ec576..138929602b 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -19,7 +19,6 @@ pub use crate::common::MemorySummarizeInput; pub use crate::common::MemorySummarizeOutput; pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; -pub use crate::common::ResponseAppendWsRequest; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 3c039380c9..d01e1d918a 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -118,14 +118,6 @@ struct ResponseCompleted { usage: Option, } -#[derive(Debug, Deserialize)] -struct ResponseDone { - #[serde(default)] - id: Option, - #[serde(default)] - usage: Option, -} - #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { input_tokens: i64, @@ -324,7 +316,6 @@ pub fn process_responses_event( return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), - can_append: false, })); } Err(err) => { @@ -335,31 +326,6 @@ pub fn process_responses_event( } } } - "response.done" => { - if let Some(resp_val) = event.response { - match serde_json::from_value::(resp_val) { - Ok(resp) => { - return Ok(Some(ResponseEvent::Completed { - response_id: resp.id.unwrap_or_default(), - token_usage: resp.usage.map(Into::into), - can_append: true, - })); - } - Err(err) => { - let error = format!("failed to parse ResponseCompleted: {err}"); - debug!("{error}"); - return Err(ResponsesEventError::Api(ApiError::Stream(error))); - } - } - } - - debug!("response.done missing response payload"); - return Ok(Some(ResponseEvent::Completed { - response_id: String::new(), - token_usage: None, - can_append: true, - })); - } "response.output_item.added" => { if let Some(item_val) = event.item { if let Ok(item) = serde_json::from_value::(item_val) { @@ -639,11 +605,9 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected third event: {other:?}"), } @@ -677,69 +641,6 @@ mod tests { } } - #[tokio::test] - async fn response_done_emits_incremental_completed() { - let done = json!({ - "type": "response.done", - "response": { - "usage": { - "input_tokens": 1, - "input_tokens_details": null, - "output_tokens": 2, - "output_tokens_details": null, - "total_tokens": 3 - } - } - }) - .to_string(); - - let sse1 = format!("event: response.done\ndata: {done}\n\n"); - - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append, - }) => { - assert_eq!(response_id, ""); - assert!(token_usage.is_some()); - assert!(*can_append); - } - other => panic!("unexpected event: {other:?}"), - } - } - - #[tokio::test] - async fn response_done_without_payload_emits_completed() { - let done = json!({ - "type": "response.done" - }) - .to_string(); - - let sse1 = format!("event: response.done\ndata: {done}\n\n"); - - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Ok(ResponseEvent::Completed { - response_id, - token_usage, - can_append, - }) => { - assert_eq!(response_id, ""); - assert!(token_usage.is_none()); - assert!(*can_append); - } - other => panic!("unexpected event: {other:?}"), - } - } - #[tokio::test] async fn emits_completed_without_stream_end() { let completed = json!({ @@ -770,11 +671,9 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected event: {other:?}"), } @@ -996,8 +895,7 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None, - can_append: false + token_usage: None } if response_id == "resp-1" ); } @@ -1033,8 +931,7 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None, - can_append: false + token_usage: None } if response_id == "resp-1" ); } diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index ca840d1b9f..f625fdcd9d 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -159,11 +159,9 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> ResponseEvent::Completed { response_id, token_usage, - can_append, } => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); - assert!(!can_append); } other => panic!("unexpected third event: {other:?}"), } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 534b1ec5e4..9e5974b487 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -20,9 +20,8 @@ //! //! ## Retry-Budget Tradeoff //! -//! V2 request prewarm is treated as the first websocket connection attempt for a turn. If it -//! fails, normal stream retry/fallback logic handles recovery on the same turn. V1 prewarm -//! remains connection-only. +//! WebSocket prewarm is treated as the first websocket connection attempt for a turn. If it +//! fails, normal stream retry/fallback logic handles recovery on the same turn. use std::collections::HashMap; use std::sync::Arc; @@ -43,7 +42,6 @@ use codex_api::MemorySummarizeOutput as ApiMemorySummarizeOutput; use codex_api::RawMemory as ApiRawMemory; use codex_api::RequestTelemetry; use codex_api::ReqwestTransport; -use codex_api::ResponseAppendWsRequest; use codex_api::ResponseCreateWsRequest; use codex_api::ResponsesApiRequest; use codex_api::ResponsesClient as ApiResponsesClient; @@ -101,32 +99,19 @@ use crate::model_provider_info::WireApi; use crate::tools::spec::create_tools_json_for_responses_api; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; -pub const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ResponsesWebsocketVersion { - V1, - V2, -} - -pub fn ws_version_from_features(config: &Config) -> Option { - match ( - config +pub fn ws_version_from_features(config: &Config) -> bool { + config + .features + .enabled(crate::features::Feature::ResponsesWebsockets) + || config .features - .enabled(crate::features::Feature::ResponsesWebsockets), - config - .features - .enabled(crate::features::Feature::ResponsesWebsocketsV2), - ) { - (_, true) => Some(ResponsesWebsocketVersion::V2), - (true, false) => Some(ResponsesWebsocketVersion::V1), - (false, false) => None, - } + .enabled(crate::features::Feature::ResponsesWebsocketsV2) } /// Session-scoped state shared by all [`ModelClient`] clones. @@ -140,7 +125,7 @@ struct ModelClientState { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - responses_websocket_version: Option, + responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -180,8 +165,8 @@ pub struct ModelClient { /// The session establishes a Responses WebSocket connection lazily and reuses it across multiple /// requests within the turn. It also caches per-turn state: /// -/// - The last full request, so subsequent calls can use `response.append` only when the current -/// request is an incremental extension of the previous one. +/// - The last full request, so subsequent calls can reuse incremental websocket request payloads +/// only when the current request is an incremental extension of the previous one. /// - The `x-codex-turn-state` sticky-routing token, which must be replayed for all requests within /// the same turn. /// @@ -208,7 +193,6 @@ pub struct ModelClientSession { struct LastResponse { response_id: String, items_added: Vec, - can_append: bool, } #[derive(Debug, Default)] @@ -235,7 +219,7 @@ impl ModelClient { provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, - responses_websocket_version: Option, + responses_websockets_enabled_by_feature: bool, enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, @@ -247,7 +231,7 @@ impl ModelClient { provider, session_source, model_verbosity, - responses_websocket_version, + responses_websockets_enabled_by_feature, enable_request_compression, include_timing_metrics, beta_features_header, @@ -391,25 +375,21 @@ impl ModelClient { request_telemetry } - /// Returns the active Responses-over-WebSocket version for this session. + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// This combines provider capability and feature gating; both must be true for websocket paths /// to be eligible. /// /// If websockets are only enabled via model preference (no explicit feature flag), prefer the /// current v2 behavior. - pub fn active_ws_version(&self, model_info: &ModelInfo) -> Option { + pub fn responses_websocket_enabled(&self, model_info: &ModelInfo) -> bool { if !self.state.provider.supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) { - return None; + return false; } - match self.state.responses_websocket_version { - Some(version) => Some(version), - None if model_info.prefer_websockets => Some(ResponsesWebsocketVersion::V2), - None => None, - } + self.state.responses_websockets_enabled_by_feature || model_info.prefer_websockets } /// Returns auth + provider configuration resolved from the current session auth state. @@ -442,12 +422,10 @@ impl ModelClient { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, - ws_version: ResponsesWebsocketVersion, turn_state: Option>>, turn_metadata_header: Option<&str>, ) -> std::result::Result { - let headers = - self.build_websocket_headers(ws_version, turn_state.as_ref(), turn_metadata_header); + let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); let websocket_telemetry = ModelClientSession::build_websocket_telemetry(otel_manager); ApiWebSocketResponsesClient::new(api_provider, api_auth) .connect( @@ -465,7 +443,6 @@ impl ModelClient { /// replayed on reconnect within the same turn. fn build_websocket_headers( &self, - ws_version: ResponsesWebsocketVersion, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { @@ -478,13 +455,9 @@ impl ModelClient { headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); - let responses_websockets_beta_header = match ws_version { - ResponsesWebsocketVersion::V2 => RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE, - ResponsesWebsocketVersion::V1 => OPENAI_BETA_RESPONSES_WEBSOCKETS, - }; headers.insert( OPENAI_BETA_HEADER, - HeaderValue::from_static(responses_websockets_beta_header), + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), ); if self.state.include_timing_metrics { headers.insert( @@ -613,8 +586,9 @@ impl ModelClientSession { last_response: Option<&LastResponse>, allow_empty_delta: bool, ) -> Option> { - // Checks whether the current request is an incremental append to the previous request. - // We only append when non-input request fields are unchanged and `input` is a strict + // Checks whether the current request is an incremental extension of the previous request. + // We only reuse an incremental input delta when non-input request fields are unchanged and + // `input` is a strict // extension of the previous known input. Server-returned output items are treated as part // of the baseline so we do not resend them. let previous_request = self.websocket_session.last_request.as_ref()?; @@ -659,42 +633,26 @@ impl ModelClientSession { &mut self, payload: ResponseCreateWsRequest, request: &ResponsesApiRequest, - ws_version: ResponsesWebsocketVersion, ) -> ResponsesWsRequest { let Some(last_response) = self.get_last_response() else { return ResponsesWsRequest::ResponseCreate(payload); }; - let allow_empty_delta = matches!(ws_version, ResponsesWebsocketVersion::V2); - let Some(append_items) = - self.get_incremental_items(request, Some(&last_response), allow_empty_delta) + let Some(incremental_items) = + self.get_incremental_items(request, Some(&last_response), true) else { return ResponsesWsRequest::ResponseCreate(payload); }; - match ws_version { - ResponsesWebsocketVersion::V2 => { - if last_response.response_id.is_empty() { - trace!("incremental request failed, no previous response id"); - return ResponsesWsRequest::ResponseCreate(payload); - } - - ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { - previous_response_id: Some(last_response.response_id), - input: append_items, - ..payload - }) - } - ResponsesWebsocketVersion::V1 => { - if !last_response.can_append { - trace!("incremental request failed, can't append"); - return ResponsesWsRequest::ResponseCreate(payload); - } - ResponsesWsRequest::ResponseAppend(ResponseAppendWsRequest { - input: append_items, - client_metadata: payload.client_metadata, - }) - } + if last_response.response_id.is_empty() { + trace!("incremental request failed, no previous response id"); + return ResponsesWsRequest::ResponseCreate(payload); } + + ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { + previous_response_id: Some(last_response.response_id), + input: incremental_items, + ..payload + }) } /// Opportunistically preconnects a websocket for this turn-scoped client session. @@ -705,9 +663,9 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> std::result::Result<(), ApiError> { - let Some(ws_version) = self.client.active_ws_version(model_info) else { + if !self.client.responses_websocket_enabled(model_info) { return Ok(()); - }; + } if self.websocket_session.connection.is_some() { return Ok(()); } @@ -724,7 +682,6 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, - ws_version, Some(Arc::clone(&self.turn_state)), None, ) @@ -738,7 +695,6 @@ impl ModelClientSession { otel_manager: &OtelManager, api_provider: codex_api::Provider, api_auth: CoreAuthProvider, - ws_version: ResponsesWebsocketVersion, turn_metadata_header: Option<&str>, options: &ApiResponsesOptions, ) -> std::result::Result<&ApiWebSocketConnection, ApiError> { @@ -760,7 +716,6 @@ impl ModelClientSession { otel_manager, api_provider, api_auth, - ws_version, Some(turn_state), turn_metadata_header, ) @@ -862,7 +817,6 @@ impl ModelClientSession { &mut self, prompt: &Prompt, model_info: &ModelInfo, - ws_version: ResponsesWebsocketVersion, otel_manager: &OtelManager, effort: Option, summary: ReasoningSummaryConfig, @@ -901,7 +855,6 @@ impl ModelClientSession { otel_manager, client_setup.api_provider, client_setup.api_auth, - ws_version, turn_metadata_header, &options, ) @@ -922,7 +875,7 @@ impl ModelClientSession { Err(err) => return Err(map_api_error(err)), } - let ws_request = self.prepare_websocket_request(ws_payload, &request, ws_version); + let ws_request = self.prepare_websocket_request(ws_payload, &request); self.websocket_session.last_request = Some(request); let stream_result = self .websocket_session @@ -971,17 +924,10 @@ impl ModelClientSession { service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { - let Some(ws_version) = self.client.active_ws_version(model_info) else { - return Ok(()); - }; - if self.websocket_session.last_request.is_some() { + if !self.client.responses_websocket_enabled(model_info) { return Ok(()); } - - if matches!(ws_version, ResponsesWebsocketVersion::V1) { - self.preconnect_websocket(otel_manager, model_info) - .await - .map_err(map_api_error)?; + if self.websocket_session.last_request.is_some() { return Ok(()); } @@ -989,7 +935,6 @@ impl ModelClientSession { .stream_responses_websocket( prompt, model_info, - ws_version, otel_manager, effort, summary, @@ -1038,12 +983,11 @@ impl ModelClientSession { let wire_api = self.client.state.provider.wire_api; match wire_api { WireApi::Responses => { - if let Some(ws_version) = self.client.active_ws_version(model_info) { + if self.client.responses_websocket_enabled(model_info) { match self .stream_responses_websocket( prompt, model_info, - ws_version, otel_manager, effort, summary, @@ -1085,7 +1029,7 @@ impl ModelClientSession { otel_manager: &OtelManager, model_info: &ModelInfo, ) -> bool { - let websocket_enabled = self.client.active_ws_version(model_info).is_some(); + let websocket_enabled = self.client.responses_websocket_enabled(model_info); let activated = self.activate_http_fallback(websocket_enabled); if activated { warn!("falling back to HTTP"); @@ -1183,7 +1127,6 @@ where Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, }) => { if let Some(usage) = &token_usage { otel_manager.sse_event_completed( @@ -1198,14 +1141,12 @@ where let _ = sender.send(LastResponse { response_id: response_id.clone(), items_added: std::mem::take(&mut items_added), - can_append, }); } if tx_event .send(Ok(ResponseEvent::Completed { response_id, token_usage, - can_append, })) .await .is_err() @@ -1335,7 +1276,7 @@ mod tests { provider, session_source, None, - None, + false, false, false, None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7b540aa4d8..3a28a3e83b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5572,12 +5572,10 @@ async fn run_sampling_request( // transient reconnect messages. In debug builds, keep full visibility for diagnosis. let report_error = retries > 1 || cfg!(debug_assertions) - || sess + || !sess .services .model_client - .active_ws_version(&turn_context.model_info) - .is_none(); - + .responses_websocket_enabled(&turn_context.model_info); if report_error { // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring @@ -6418,7 +6416,6 @@ async fn try_run_sampling_request( ResponseEvent::Completed { response_id: _, token_usage, - can_append: _, } => { flush_assistant_text_segments_all( &sess, @@ -9194,7 +9191,7 @@ mod tests { handlers::run_user_shell_command(&session, "sub-id".to_string(), "echo shell".to_string()) .await; - let deadline = StdDuration::from_secs(5); + let deadline = StdDuration::from_secs(15); let start = std::time::Instant::now(); loop { let remaining = deadline.saturating_sub(start.elapsed()); diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index baabf8a42f..6c7dbb1050 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -109,7 +109,8 @@ async fn run_compact_task_inner( let max_retries = turn_context.provider.stream_max_retries(); let mut retries = 0; let mut client_session = sess.services.model_client.new_session(); - // Reuse one client session so turn-scoped state (sticky routing, websocket append tracking) + // Reuse one client session so turn-scoped state (sticky routing, websocket incremental + // request tracking) // survives retries within this compact turn. loop { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index ef988f1c52..828bbe214a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -147,7 +147,6 @@ pub(crate) use codex_shell_command::powershell; pub use client::ModelClient; pub use client::ModelClientSession; -pub use client::ResponsesWebsocketVersion; pub use client::X_CODEX_TURN_METADATA_HEADER; pub use client::ws_version_from_features; pub use client_common::Prompt; diff --git a/codex-rs/core/templates/tools/presentation_artifact.md b/codex-rs/core/templates/tools/presentation_artifact.md index a5aca8916c..7247f57b15 100644 --- a/codex-rs/core/templates/tools/presentation_artifact.md +++ b/codex-rs/core/templates/tools/presentation_artifact.md @@ -14,6 +14,7 @@ Supported actions: - `get_summary` - `list_slides` - `list_layouts` +- `list_masters` - `list_layout_placeholders` - `list_slide_placeholders` - `inspect` @@ -63,6 +64,8 @@ Supported actions: - `insert_text_after` - `set_hyperlink` - `set_comment_author` +- `list_comment_threads` +- `get_comment_thread` - `add_comment_thread` - `add_comment_reply` - `toggle_comment_reaction` @@ -104,6 +107,8 @@ Example layout flow: Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name. +Use `list_masters` when you only want layouts with `kind: "master"` instead of the full mixed layout list. + `insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet. Example inspect: @@ -124,6 +129,14 @@ Rich text is supported on notes, text boxes, shapes with text, and table cells. Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/`, and comment records appear in both `inspect` and `to_proto`. +Use `list_comment_threads` for an explicit collection payload and `get_comment_thread` when you already know the thread id. + +Example list comment threads: +`{"artifact_id":"presentation_x","actions":[{"action":"list_comment_threads","args":{}}]}` + +Example get comment thread: +`{"artifact_id":"presentation_x","actions":[{"action":"get_comment_thread","args":{"thread_id":"thread_1"}}]}` + Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state. Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy. diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index c1a40e90fc..4f2e89636e 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -582,25 +582,6 @@ pub fn ev_completed(id: &str) -> Value { }) } -pub fn ev_done() -> Value { - serde_json::json!({ - "type": "response.done", - "response": { - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - -pub fn ev_done_with_id(id: &str) -> Value { - serde_json::json!({ - "type": "response.done", - "response": { - "id": id, - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - /// Convenience: SSE event for a created response with a specific id. pub fn ev_response_created(id: &str) -> Value { serde_json::json!({ diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index a46c45f234..be41a25a0f 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -6,7 +6,6 @@ use codex_core::ModelClient; use codex_core::ModelProviderInfo; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponsesWebsocketVersion; use codex_core::WireApi; use codex_otel::OtelManager; use codex_otel::TelemetryAuthMode; @@ -92,7 +91,7 @@ async fn responses_stream_includes_subagent_header_on_review() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, @@ -205,7 +204,7 @@ async fn responses_stream_includes_subagent_header_on_other() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, @@ -317,7 +316,7 @@ async fn responses_respects_model_info_overrides_from_config() { provider.clone(), session_source, config.model_verbosity, - None::, + false, false, false, None, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index fdd9f316d6..0db3cf61ad 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -4,8 +4,6 @@ use codex_protocol::config_types::ServiceTier; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; -use core_test_support::responses::ev_done_with_id; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call; use core_test_support::responses::start_websocket_server; @@ -27,7 +25,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { vec![ ev_response_created("resp-1"), ev_shell_command_call(call_id, "echo websocket"), - ev_done(), + ev_completed("resp-1"), ], vec![ ev_response_created("resp-2"), @@ -59,36 +57,30 @@ async fn websocket_test_codex_shell_chain() -> Result<()> { .body_json(); assert_eq!(first_turn["type"].as_str(), Some("response.create")); - assert_eq!(second_turn["type"].as_str(), Some("response.append")); + assert_eq!(second_turn["type"].as_str(), Some("response.create")); - let append_items = second_turn + let input_items = second_turn .get("input") .and_then(Value::as_array) - .expect("response.append input array"); - assert!(!append_items.is_empty()); - - let output_item = append_items - .iter() - .find(|item| item.get("type").and_then(Value::as_str) == Some("function_call_output")) - .expect("function_call_output in append"); - assert_eq!( - output_item.get("call_id").and_then(Value::as_str), - Some(call_id) - ); + .expect("second response.create input array"); + assert!(!input_items.is_empty()); server.shutdown().await; Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { +async fn websocket_first_turn_uses_startup_prewarm_and_create() -> Result<()> { skip_if_no_network!(Ok(())); - let server = start_websocket_server(vec![vec![vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), - ev_completed("resp-1"), - ]]]) + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello"), + ev_completed("resp-1"), + ], + ]]) .await; let mut builder = test_codex(); @@ -101,11 +93,14 @@ async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); - assert_eq!(connection.len(), 1); - let turn = connection + assert_eq!(connection.len(), 2); + let warmup = connection .first() - .expect("missing turn request") + .expect("missing warmup request") .body_json(); + let turn = connection.get(1).expect("missing turn request").body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); assert!( turn["tools"] .as_array() @@ -119,15 +114,18 @@ async fn websocket_first_turn_uses_preconnect_and_create() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn websocket_first_turn_handles_handshake_delay_with_preconnect() -> Result<()> { +async fn websocket_first_turn_handles_handshake_delay_with_startup_prewarm() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_websocket_server_with_headers(vec![WebSocketConnectionConfig { - requests: vec![vec![ - ev_response_created("resp-1"), - ev_assistant_message("msg-1", "hello"), - ev_completed("resp-1"), - ]], + requests: vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "hello"), + ev_completed("resp-1"), + ], + ], response_headers: Vec::new(), // Delay handshake so turn processing must tolerate websocket startup latency. accept_delay: Some(Duration::from_millis(150)), @@ -144,11 +142,14 @@ async fn websocket_first_turn_handles_handshake_delay_with_preconnect() -> Resul assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); - assert_eq!(connection.len(), 1); - let turn = connection + assert_eq!(connection.len(), 2); + let warmup = connection .first() - .expect("missing turn request") + .expect("missing warmup request") .body_json(); + let turn = connection.get(1).expect("missing turn request").body_json(); + assert_eq!(warmup["type"].as_str(), Some("response.create")); + assert_eq!(warmup["generate"].as_bool(), Some(false)); assert!( turn["tools"] .as_array() @@ -167,7 +168,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { let call_id = "shell-command-call"; let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), ev_shell_command_call(call_id, "echo websocket"), @@ -251,7 +252,7 @@ async fn websocket_v2_first_turn_uses_updated_fast_tier_after_startup_prewarm() skip_if_no_network!(Ok(())); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "fast"), @@ -300,7 +301,7 @@ async fn websocket_v2_first_turn_drops_fast_tier_after_startup_prewarm() -> Resu skip_if_no_network!(Ok(())); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "standard"), @@ -349,7 +350,7 @@ async fn websocket_v2_next_turn_uses_updated_service_tier() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "fast"), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 8521c91eb1..d4a8ce9f3e 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -4,7 +4,6 @@ use codex_core::ModelProviderInfo; use codex_core::NewThread; use codex_core::Prompt; use codex_core::ResponseEvent; -use codex_core::ResponsesWebsocketVersion; use codex_core::ThreadManager; use codex_core::WireApi; use codex_core::auth::AuthCredentialsStoreMode; @@ -1643,7 +1642,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { provider.clone(), SessionSource::Exec, config.model_verbosity, - None::, + false, false, false, None, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 2c2dc7c7cf..b2098c039c 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -31,8 +31,6 @@ use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::WebSocketTestServer; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; -use core_test_support::responses::ev_done_with_id; use core_test_support::responses::ev_response_created; use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; @@ -50,7 +48,6 @@ use tracing_test::traced_test; const MODEL: &str = "gpt-5.2-codex"; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; -const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; struct WebsocketTestHarness { @@ -89,7 +86,7 @@ async fn responses_websocket_streams_request() { let handshake = server.single_handshake(); assert_eq!( handshake.header(OPENAI_BETA_HEADER), - Some(OPENAI_BETA_RESPONSES_WEBSOCKETS.to_string()) + Some(WS_V2_BETA_HEADER_VALUE.to_string()) ); server.shutdown().await; @@ -125,7 +122,7 @@ async fn responses_websocket_request_prewarm_reuses_connection() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ev_response_created("resp-1"), ev_completed("resp-1")], ]]) .await; @@ -244,7 +241,7 @@ async fn responses_websocket_request_prewarm_is_reused_even_with_header_changes( skip_if_no_network!(); let server = start_websocket_server(vec![vec![ - vec![ev_response_created("warm-1"), ev_done_with_id("warm-1")], + vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ev_response_created("resp-1"), ev_completed("resp-1")], ]]) .await; @@ -344,13 +341,6 @@ async fn responses_websocket_prewarm_uses_v2_when_model_prefers_websockets_and_f .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - stream_until_complete(&mut client_session, &harness, &prompt).await; assert_eq!(server.handshakes().len(), 1); let connection = server.single_connection(); @@ -404,13 +394,6 @@ async fn responses_websocket_preconnect_runs_when_only_v2_feature_enabled() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -422,7 +405,7 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -460,13 +443,6 @@ async fn responses_websocket_v2_requests_use_v2_when_model_prefers_websockets() .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -478,7 +454,7 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -522,7 +498,7 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -560,13 +536,6 @@ async fn responses_websocket_v2_wins_when_both_features_enabled() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } @@ -835,7 +804,14 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { } }); - let server = start_websocket_server(vec![vec![vec![usage_limit_error]]]).await; + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![usage_limit_error], + ]]) + .await; let mut builder = test_codex().with_config(|config| { config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); @@ -913,7 +889,14 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { } }); - let server = start_websocket_server(vec![vec![vec![invalid_request_error]]]).await; + let server = start_websocket_server(vec![vec![ + vec![ + ev_response_created("resp-prewarm"), + ev_completed("resp-prewarm"), + ], + vec![invalid_request_error], + ]]) + .await; let mut builder = test_codex().with_config(|config| { config.model_provider.request_max_retries = Some(0); config.model_provider.stream_max_retries = Some(0); @@ -990,14 +973,14 @@ async fn responses_websocket_connection_limit_error_reconnects_and_completes() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_appends_on_prefix() { +async fn responses_websocket_uses_incremental_create_on_prefix() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done(), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1024,24 +1007,25 @@ async fn responses_websocket_appends_on_prefix() { assert_eq!(first["model"].as_str(), Some(MODEL)); assert_eq!(first["stream"], serde_json::Value::Bool(true)); assert_eq!(first["input"].as_array().map(Vec::len), Some(1)); - let expected_append = serde_json::json!({ - "type": "response.append", - "input": serde_json::to_value(&prompt_two.input[2..]).expect("serialize append items"), - }); - assert_eq!(second, expected_append); + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); + assert_eq!( + second["input"], + serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental items") + ); server.shutdown().await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { +async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done(), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1085,7 +1069,8 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { first["client_metadata"]["x-codex-turn-metadata"].as_str(), Some(first_turn_metadata) ); - assert_eq!(second["type"].as_str(), Some("response.append")); + assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); assert_eq!( second["client_metadata"]["x-codex-turn-metadata"].as_str(), Some(enriched_turn_metadata) @@ -1107,7 +1092,7 @@ async fn responses_websocket_forwards_turn_metadata_on_create_and_append() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_websocket_creates_on_prefix_when_previous_completion_cannot_append() { +async fn responses_websocket_uses_previous_response_id_when_prefix_after_completed() { skip_if_no_network!(); let server = start_websocket_server(vec![vec![ @@ -1137,9 +1122,10 @@ async fn responses_websocket_creates_on_prefix_when_previous_completion_cannot_a let second = connection.get(1).expect("missing request").body_json(); assert_eq!(second["type"].as_str(), Some("response.create")); + assert_eq!(second["previous_response_id"].as_str(), Some("resp-1")); assert_eq!( second["input"], - serde_json::to_value(&prompt_two.input).expect("serialize full input") + serde_json::to_value(&prompt_two.input[2..]).expect("serialize incremental input") ); server.shutdown().await; @@ -1222,7 +1208,7 @@ async fn responses_websocket_v2_creates_with_previous_response_id_on_prefix() { vec![ ev_response_created("resp-1"), ev_assistant_message("msg-1", "assistant output"), - ev_done_with_id("resp-1"), + ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], ]]) @@ -1409,13 +1395,6 @@ async fn responses_websocket_v2_sets_openai_beta_header() { .map(str::trim) .any(|value| value == WS_V2_BETA_HEADER_VALUE) ); - assert!( - !openai_beta_header - .split(',') - .map(str::trim) - .any(|value| value == OPENAI_BETA_RESPONSES_WEBSOCKETS) - ); - server.shutdown().await; } diff --git a/codex-rs/core/tests/suite/presentation_artifact.rs b/codex-rs/core/tests/suite/presentation_artifact.rs index f3d6e743f9..affd7b2a9b 100644 --- a/codex-rs/core/tests/suite/presentation_artifact.rs +++ b/codex-rs/core/tests/suite/presentation_artifact.rs @@ -3,6 +3,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_core::CodexAuth; +use codex_core::features::Feature; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -46,7 +47,12 @@ async fn presentation_artifact_render_preview_returns_inline_image() -> anyhow:: cwd, session_configured, .. - } = test_codex().build(&server).await?; + } = test_codex() + .with_config(|config| { + config.features.enable(Feature::Artifact); + }) + .build(&server) + .await?; let call_id = "presentation-render-preview"; let arguments = serde_json::json!({ @@ -206,6 +212,7 @@ async fn presentation_artifact_render_preview_fails_for_text_only_model() -> any let TestCodex { codex, cwd, .. } = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { + config.features.enable(Feature::Artifact); config.model = Some(model_slug.to_string()); }) .build(&server) diff --git a/codex-rs/core/tests/suite/turn_state.rs b/codex-rs/core/tests/suite/turn_state.rs index 62bd974393..c068cfafb1 100644 --- a/codex-rs/core/tests/suite/turn_state.rs +++ b/codex-rs/core/tests/suite/turn_state.rs @@ -4,7 +4,6 @@ use anyhow::Result; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_done; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_shell_command_call; @@ -100,7 +99,7 @@ async fn websocket_turn_state_persists_within_turn_and_resets_after() -> Result< ev_response_created("resp-1"), ev_reasoning_item("rsn-1", &["thinking"], &[]), ev_shell_command_call(call_id, "echo websocket"), - ev_done(), + ev_completed("resp-1"), ]], response_headers: vec![(TURN_STATE_HEADER.to_string(), "ts-1".to_string())], accept_delay: None, diff --git a/codex-rs/core/tests/suite/websocket_fallback.rs b/codex-rs/core/tests/suite/websocket_fallback.rs index 5be4bc9109..fadd427c16 100644 --- a/codex-rs/core/tests/suite/websocket_fallback.rs +++ b/codex-rs/core/tests/suite/websocket_fallback.rs @@ -66,9 +66,9 @@ async fn websocket_fallback_switches_to_http_on_upgrade_required_connect() -> Re .filter(|req| req.method == Method::POST && req.url.path().ends_with("/responses")) .count(); - // Startup prewarm now only preconnects for v1 (one websocket GET with no request body). - // The first turn then attempts websocket once, sees 426, and falls back to HTTP. - assert_eq!(websocket_attempts, 2); + // The startup prewarm request sees 426 and immediately switches the session to HTTP fallback, + // so the first turn goes straight to HTTP with no additional websocket connect attempt. + assert_eq!(websocket_attempts, 1); assert_eq!(http_attempts, 1); assert_eq!(response_mock.requests().len(), 1); diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 763f2602ad..b4e3adfe53 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -1,38 +1,36 @@ # Codex MCP Server Interface [experimental] -This document describes Codex’s experimental MCP server interface: a JSON‑RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. +This document describes Codex's experimental MCP server interface: a JSON-RPC API that runs over the Model Context Protocol (MCP) transport to control a local Codex engine. - Status: experimental and subject to change without notice - Server binary: `codex mcp-server` (or `codex-mcp-server`) -- Transport: standard MCP over stdio (JSON‑RPC 2.0, line‑delimited) +- Transport: standard MCP over stdio (JSON-RPC 2.0, line-delimited) ## Overview -Codex exposes a small set of MCP‑compatible methods to create and manage conversations, send user input, receive live events, and handle approval prompts. The types are defined in `protocol/src/mcp_protocol.rs` and re‑used by the MCP server implementation in `mcp-server/`. +Codex exposes MCP-compatible methods to manage threads, turns, accounts, config, and approvals. The types live in `app-server-protocol/src/protocol/{common,v1,v2}.rs` and are consumed by the app server implementation in `app-server/`. At a glance: -- Conversations - - `newConversation` → start a Codex session - - `sendUserMessage` / `sendUserTurn` → send user input into a conversation - - `interruptConversation` → stop the current turn - - `listConversations`, `resumeConversation`, `archiveConversation` -- Configuration and info - - `getUserSavedConfig`, `setDefaultModel`, `getUserAgent`, `userInfo` - - `model/list` → enumerate available models and reasoning options - - `collaborationMode/list` → enumerate collaboration mode presets (experimental) -- Auth +- Primary v2 RPCs + - `thread/start`, `thread/resume`, `thread/fork`, `thread/read`, `thread/list` + - `turn/start`, `turn/steer`, `turn/interrupt` - `account/read`, `account/login/start`, `account/login/cancel`, `account/logout`, `account/rateLimits/read` - - notifications: `account/login/completed`, `account/updated`, `account/rateLimits/updated` -- Utilities - - `gitDiffToRemote`, `execOneOffCommand` -- Approvals (server → client requests) + - `config/read`, `config/value/write`, `config/batchWrite` + - `model/list`, `app/list`, `collaborationMode/list` +- Remaining v1 compatibility RPCs + - `getConversationSummary` + - `getAuthStatus` + - `gitDiffToRemote` + - `fuzzyFileSearch`, `fuzzyFileSearch/sessionStart`, `fuzzyFileSearch/sessionUpdate`, `fuzzyFileSearch/sessionStop` +- Notifications + - v2 typed notifications such as `thread/started`, `turn/completed`, `account/login/completed` + - `codex/event/*` stream notifications for live agent events + - `fuzzyFileSearch/sessionUpdated`, `fuzzyFileSearch/sessionCompleted` +- Approvals (server -> client requests) - `applyPatchApproval`, `execCommandApproval` -- Notifications (server → client) - - `loginChatGptComplete`, `authStatusChange` - - `codex/event` stream with agent events -See code for full type definitions and exact shapes: `protocol/src/mcp_protocol.rs`. +See code for full type definitions and exact shapes: `app-server-protocol/src/protocol/{common,v1,v2}.rs`. ## Starting the server @@ -50,73 +48,45 @@ npx @modelcontextprotocol/inspector codex mcp-server Use the separate `codex mcp` subcommand to manage configured MCP server launchers in `config.toml`. -## Conversations +## Threads and turns -Start a new session with optional overrides: +Use the v2 thread and turn APIs for all new integrations. `thread/start` creates a thread, `turn/start` submits user input, `turn/interrupt` stops an in-flight turn, and `thread/list` / `thread/read` expose persisted history. -Request `newConversation` params (subset): +`getConversationSummary` remains as a compatibility helper for clients that still need a summary lookup by `conversationId` or `rolloutPath`. -- `model`: string model id (e.g. "o3", "gpt-5.1", "gpt-5.1-codex") -- `profile`: optional named profile -- `cwd`: optional working directory -- `approvalPolicy`: `untrusted` | `on-request` | `on-failure` (deprecated) | `never` -- `sandbox`: `read-only` | `workspace-write` | `external-sandbox` (honors `networkAccess` restricted/enabled) | `danger-full-access` -- `config`: map of additional config overrides -- `baseInstructions`: optional instruction override -- `compactPrompt`: optional replacement for the default compaction prompt -- `includePlanTool` / `includeApplyPatchTool`: booleans - -Response: `{ conversationId, model, reasoningEffort?, rolloutPath }` - -Send input to the active turn: - -- `sendUserMessage` → enqueue items to the conversation -- `sendUserTurn` → structured turn with explicit `cwd`, `approvalPolicy`, `sandboxPolicy`, `model`, optional `effort`, `summary`, optional `personality`, and optional `outputSchema` (JSON Schema for the final assistant message) - -Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. - -For v2 threads, `turn/start` also accepts `outputSchema` to constrain the final assistant message for that turn. - -Interrupt a running turn: `interruptConversation`. - -List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`. - -For v2 threads, use `thread/list` with filters such as `archived: true`, `cwd: "/path"`, or -`searchTerm: "needle"` to -narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions -directory (it returns the restored thread summary). +For complete request and response shapes, see the app-server README and the protocol definitions in `app-server-protocol/src/protocol/v2.rs`. ## Models Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs: -- `limit` – number of models to return (defaults to a server-selected value) -- `cursor` – opaque string from the previous response’s `nextCursor` +- `limit` - number of models to return (defaults to a server-selected value) +- `cursor` - opaque string from the previous response's `nextCursor` Each response yields: -- `data` – ordered list of models. A model includes: +- `data` - ordered list of models. A model includes: - `id`, `model`, `displayName`, `description` - - `supportedReasoningEfforts` – array of objects with: - - `reasoningEffort` – one of `none|minimal|low|medium|high|xhigh` - - `description` – human-friendly label for the effort - - `defaultReasoningEffort` – suggested effort for the UI - - `inputModalities` – accepted input types for the model - - `supportsPersonality` – whether the model supports personality-specific instructions - - `isDefault` – whether the model is recommended for most users - - `upgrade` – optional recommended upgrade model id - - `upgradeInfo` – optional upgrade metadata object with: - - `model` – recommended upgrade model id - - `upgradeCopy` – optional display copy for the upgrade recommendation - - `modelLink` – optional link for the upgrade recommendation - - `migrationMarkdown` – optional markdown shown when presenting the upgrade -- `nextCursor` – pass into the next request to continue paging (optional) + - `supportedReasoningEfforts` - array of objects with: + - `reasoningEffort` - one of `none|minimal|low|medium|high|xhigh` + - `description` - human-friendly label for the effort + - `defaultReasoningEffort` - suggested effort for the UI + - `inputModalities` - accepted input types for the model + - `supportsPersonality` - whether the model supports personality-specific instructions + - `isDefault` - whether the model is recommended for most users + - `upgrade` - optional recommended upgrade model id + - `upgradeInfo` - optional upgrade metadata object with: + - `model` - recommended upgrade model id + - `upgradeCopy` - optional display copy for the upgrade recommendation + - `modelLink` - optional link for the upgrade recommendation + - `migrationMarkdown` - optional markdown shown when presenting the upgrade +- `nextCursor` - pass into the next request to continue paging (optional) ## Collaboration modes (experimental) Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response: -- `data` – ordered list of collaboration mode masks (partial settings to apply on top of the base mode) +- `data` - ordered list of collaboration mode masks (partial settings to apply on top of the base mode) - For tri-state fields like `reasoning_effort` and `developer_instructions`, omit the field to keep the current value, set it to `null` to clear it, or set a concrete value to update it. When sending `turn/start` with `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". @@ -125,16 +95,14 @@ When sending `turn/start` with `collaborationMode`, `settings.developer_instruct While a conversation runs, the server sends notifications: -- `codex/event` with the serialized Codex event payload. The shape matches `core/src/protocol.rs`’s `Event` and `EventMsg` types. Some notifications include a `_meta.requestId` to correlate with the originating request. -- Auth notifications via method names `loginChatGptComplete` and `authStatusChange`. +- `codex/event` with the serialized Codex event payload. The shape matches `core/src/protocol.rs`'s `Event` and `EventMsg` types. Some notifications include a `_meta.requestId` to correlate with the originating request. +- `fuzzyFileSearch/sessionUpdated` and `fuzzyFileSearch/sessionCompleted` for the legacy fuzzy search flow. Clients should render events and, when present, surface approval requests (see next section). ## Tool responses -The `codex` and `codex-reply` tools return standard MCP `CallToolResult` payloads. For -compatibility with MCP clients that prefer `structuredContent`, Codex mirrors the -content blocks inside `structuredContent` alongside the `threadId`. +The `codex` and `codex-reply` tools return standard MCP `CallToolResult` payloads. For compatibility with MCP clients that prefer `structuredContent`, Codex mirrors the content blocks inside `structuredContent` alongside the `threadId`. Example: @@ -148,9 +116,9 @@ Example: } ``` -## Approvals (server → client) +## Approvals (server -> client) -When Codex needs approval to apply changes or run commands, the server issues JSON‑RPC requests to the client: +When Codex needs approval to apply changes or run commands, the server issues JSON-RPC requests to the client: - `applyPatchApproval { conversationId, callId, fileChanges, reason?, grantRoot? }` - `execCommandApproval { conversationId, callId, approvalId?, command, cwd, reason? }` @@ -159,28 +127,17 @@ The client must reply with `{ decision: "allow" | "deny" }` for each request. ## Auth helpers -For the complete request/response shapes and flow examples, see the [“Auth endpoints (v2)” section in the app‑server README](../app-server/README.md#auth-endpoints-v2). +For the complete request/response shapes and flow examples, see the [Auth endpoints (v2) section in the app-server README](../app-server/README.md#auth-endpoints-v2). -## Example: start and send a message +## Legacy compatibility methods -```json -{ "jsonrpc": "2.0", "id": 1, "method": "newConversation", "params": { "model": "gpt-5.1", "approvalPolicy": "on-request" } } -``` +The server still accepts a narrow v1 compatibility surface for existing app clients: -Server responds: - -```json -{ "jsonrpc": "2.0", "id": 1, "result": { "conversationId": "c7b0…", "model": "gpt-5.1", "rolloutPath": "/path/to/rollout.jsonl" } } -``` - -Then send input: - -```json -{ "jsonrpc": "2.0", "id": 2, "method": "sendUserMessage", "params": { "conversationId": "c7b0…", "items": [{ "type": "text", "text": "Hello Codex" }] } } -``` - -While processing, the server emits `codex/event` notifications containing agent output, approvals, and status updates. +- `getConversationSummary` +- `getAuthStatus` +- `gitDiffToRemote` +- `fuzzyFileSearch`, `fuzzyFileSearch/sessionStart`, `fuzzyFileSearch/sessionUpdate`, `fuzzyFileSearch/sessionStop` ## Compatibility and stability -This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `protocol/src/mcp_protocol.rs` and the corresponding server wiring in `mcp-server/`. +This interface is experimental. Method names, fields, and event shapes may evolve. For the authoritative schema, consult `app-server-protocol/src/protocol/{common,v1,v2}.rs` and the corresponding server wiring in `app-server/`. diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index a4fe6cc185..ee8e98c39e 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -226,6 +226,7 @@ impl CodexLogSnapshot { include_logs: bool, extra_log_files: &[PathBuf], session_source: Option, + logs_override: Option>, ) -> Result<()> { use std::collections::BTreeMap; use std::fs; @@ -310,7 +311,7 @@ impl CodexLogSnapshot { if include_logs { envelope.add_item(EnvelopeItem::Attachment(Attachment { - buffer: self.bytes.clone(), + buffer: logs_override.unwrap_or_else(|| self.bytes.clone()), filename: String::from("codex-logs.log"), content_type: Some("text/plain".to_string()), ty: None, diff --git a/codex-rs/shell-command/src/parse_command.rs b/codex-rs/shell-command/src/parse_command.rs index 71018a53d1..1c8ac99485 100644 --- a/codex-rs/shell-command/src/parse_command.rs +++ b/codex-rs/shell-command/src/parse_command.rs @@ -37,7 +37,26 @@ pub fn parse_command(command: &[String]) -> Vec { } deduped.push(cmd); } - deduped + if deduped + .iter() + .any(|cmd| matches!(cmd, ParsedCommand::Unknown { .. })) + { + vec![single_unknown_for_command(command)] + } else { + deduped + } +} + +fn single_unknown_for_command(command: &[String]) -> ParsedCommand { + if let Some((_, shell_command)) = extract_shell_command(command) { + ParsedCommand::Unknown { + cmd: shell_command.to_string(), + } + } else { + ParsedCommand::Unknown { + cmd: shlex_join(command), + } + } } #[cfg(test)] @@ -119,7 +138,7 @@ mod tests { assert_parsed( &vec_str(&["bash", "-lc", inner]), vec![ParsedCommand::Unknown { - cmd: "git status".to_string(), + cmd: inner.to_string(), }], ); } @@ -141,24 +160,9 @@ mod tests { "rg --version && node -v && pnpm -v && rg --files | wc -l && rg --files | head -n 40"; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - // Expect commands in left-to-right execution order - ParsedCommand::Search { - cmd: "rg --version".to_string(), - query: None, - path: None, - }, - ParsedCommand::Unknown { - cmd: "node -v".to_string(), - }, - ParsedCommand::Unknown { - cmd: "pnpm -v".to_string(), - }, - ParsedCommand::ListFiles { - cmd: "rg --files".to_string(), - path: None, - }, - ], + vec![ParsedCommand::Unknown { + cmd: inner.to_string(), + }], ); } @@ -218,16 +222,33 @@ mod tests { let inner = r#"rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'"#; assert_parsed( &vec_str(&["bash", "-lc", inner]), - vec![ - ParsedCommand::Search { - cmd: "rg -l QkBindingController presentation/src/main/java".to_string(), - query: Some("QkBindingController".to_string()), - path: Some("java".to_string()), - }, - ParsedCommand::Unknown { - cmd: "xargs perl -pi -e s/QkBindingController/QkController/g".to_string(), - }, - ], + vec![ParsedCommand::Unknown { + cmd: inner.to_string(), + }], + ); + } + + #[test] + fn collapses_plain_pipeline_when_any_stage_is_unknown() { + let command = shlex_split_safe( + "rg -l QkBindingController presentation/src/main/java | xargs perl -pi -e 's/QkBindingController/QkController/g'", + ); + assert_parsed( + &command, + vec![ParsedCommand::Unknown { + cmd: shlex_join(&command), + }], + ); + } + + #[test] + fn collapses_pipeline_with_helper_when_later_stage_is_unknown() { + let command = shlex_split_safe("rg --files | nl -ba | foo"); + assert_parsed( + &command, + vec![ParsedCommand::Unknown { + cmd: shlex_join(&command), + }], ); } @@ -395,7 +416,7 @@ mod tests { assert_parsed( &shlex_split_safe("bash -lc 'cd foo && bar'"), vec![ParsedCommand::Unknown { - cmd: "bar".to_string(), + cmd: "cd foo && bar".to_string(), }], ); } @@ -922,85 +943,6 @@ mod tests { ); } - #[test] - fn trim_on_semicolon() { - assert_parsed( - &shlex_split_safe("rg foo ; echo done"), - vec![ - ParsedCommand::Search { - cmd: "rg foo".to_string(), - query: Some("foo".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "echo done".to_string(), - }, - ], - ); - } - - #[test] - fn split_on_or_connector() { - // Ensure we split commands on the logical OR operator as well. - assert_parsed( - &shlex_split_safe("rg foo || echo done"), - vec![ - ParsedCommand::Search { - cmd: "rg foo".to_string(), - query: Some("foo".to_string()), - path: None, - }, - ParsedCommand::Unknown { - cmd: "echo done".to_string(), - }, - ], - ); - } - - #[test] - fn parses_mixed_sequence_with_pipes_semicolons_and_or() { - // Provided long command sequence combining sequencing, pipelines, and ORs. - let inner = "pwd; ls -la; rg --files -g '!target' | wc -l; rg -n '^\\[workspace\\]' -n Cargo.toml || true; rg -n '^\\[package\\]' -n */Cargo.toml || true; cargo --version; rustc --version; cargo clippy --workspace --all-targets --all-features -q"; - let args = vec_str(&["bash", "-lc", inner]); - - let expected = vec![ - ParsedCommand::Unknown { - cmd: "pwd".to_string(), - }, - ParsedCommand::ListFiles { - cmd: shlex_join(&shlex_split_safe("ls -la")), - path: None, - }, - ParsedCommand::ListFiles { - cmd: shlex_join(&shlex_split_safe("rg --files -g '!target'")), - path: None, - }, - ParsedCommand::Search { - cmd: shlex_join(&shlex_split_safe("rg -n '^\\[workspace\\]' -n Cargo.toml")), - query: Some("^\\[workspace\\]".to_string()), - path: Some("Cargo.toml".to_string()), - }, - ParsedCommand::Search { - cmd: shlex_join(&shlex_split_safe("rg -n '^\\[package\\]' -n */Cargo.toml")), - query: Some("^\\[package\\]".to_string()), - path: Some("Cargo.toml".to_string()), - }, - ParsedCommand::Unknown { - cmd: shlex_join(&shlex_split_safe("cargo --version")), - }, - ParsedCommand::Unknown { - cmd: shlex_join(&shlex_split_safe("rustc --version")), - }, - ParsedCommand::Unknown { - cmd: shlex_join(&shlex_split_safe( - "cargo clippy --workspace --all-targets --all-features -q", - )), - }, - ]; - - assert_parsed(&args, expected); - } - #[test] fn strips_true_in_sequence() { // `true` should be dropped from parsed sequences @@ -1926,6 +1868,7 @@ fn parse_shell_lc_commands(original: &[String]) -> Option> { }; commands.push(parsed); } + if commands.len() > 1 { commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 64deeef2ad..e646828bda 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -26,6 +26,7 @@ use std::time::SystemTime; use std::time::UNIX_EPOCH; use tokio::sync::mpsc; +use tokio::sync::oneshot; use tracing::Event; use tracing::field::Field; use tracing::field::Visit; @@ -45,7 +46,7 @@ const LOG_FLUSH_INTERVAL: Duration = Duration::from_millis(250); const LOG_RETENTION_DAYS: i64 = 90; pub struct LogDbLayer { - sender: mpsc::Sender, + sender: mpsc::Sender, process_uuid: String, } @@ -61,6 +62,24 @@ pub fn start(state_db: std::sync::Arc) -> LogDbLayer { } } +impl Clone for LogDbLayer { + fn clone(&self) -> Self { + Self { + sender: self.sender.clone(), + process_uuid: self.process_uuid.clone(), + } + } +} + +impl LogDbLayer { + pub async fn flush(&self) { + let (tx, rx) = oneshot::channel(); + if self.sender.send(LogDbCommand::Flush(tx)).await.is_ok() { + let _ = rx.await; + } + } +} + impl Layer for LogDbLayer where S: tracing::Subscriber + for<'a> LookupSpan<'a>, @@ -131,10 +150,15 @@ where line: metadata.line().map(|line| line as i64), }; - let _ = self.sender.try_send(entry); + let _ = self.sender.try_send(LogDbCommand::Entry(entry)); } } +enum LogDbCommand { + Entry(LogEntry), + Flush(oneshot::Sender<()>), +} + #[derive(Clone, Debug, Default)] struct SpanLogContext { thread_id: Option, @@ -215,20 +239,24 @@ fn current_process_log_uuid() -> &'static str { async fn run_inserter( state_db: std::sync::Arc, - mut receiver: mpsc::Receiver, + mut receiver: mpsc::Receiver, ) { let mut buffer = Vec::with_capacity(LOG_BATCH_SIZE); let mut ticker = tokio::time::interval(LOG_FLUSH_INTERVAL); loop { tokio::select! { - maybe_entry = receiver.recv() => { - match maybe_entry { - Some(entry) => { + maybe_command = receiver.recv() => { + match maybe_command { + Some(LogDbCommand::Entry(entry)) => { buffer.push(entry); if buffer.len() >= LOG_BATCH_SIZE { flush(&state_db, &mut buffer).await; } } + Some(LogDbCommand::Flush(reply)) => { + flush(&state_db, &mut buffer).await; + let _ = reply.send(()); + } None => { flush(&state_db, &mut buffer).await; break; @@ -304,3 +332,150 @@ impl Visit for MessageVisitor { self.record_field(field, format!("{value:?}")); } } + +#[cfg(test)] +mod tests { + use std::io; + use std::sync::Arc; + use std::sync::Mutex; + use std::time::Duration; + + use tokio::time::Instant; + use tracing_subscriber::filter::Targets; + use tracing_subscriber::fmt::writer::MakeWriter; + use tracing_subscriber::layer::SubscriberExt; + use tracing_subscriber::util::SubscriberInitExt; + + use super::*; + + #[derive(Clone, Default)] + struct SharedWriter { + bytes: Arc>>, + } + + impl SharedWriter { + fn snapshot(&self) -> String { + String::from_utf8(self.bytes.lock().expect("writer mutex poisoned").clone()) + .expect("valid utf-8") + } + } + + struct SharedWriterGuard { + bytes: Arc>>, + } + + impl<'a> MakeWriter<'a> for SharedWriter { + type Writer = SharedWriterGuard; + + fn make_writer(&'a self) -> Self::Writer { + SharedWriterGuard { + bytes: Arc::clone(&self.bytes), + } + } + } + + impl io::Write for SharedWriterGuard { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.bytes + .lock() + .expect("writer mutex poisoned") + .extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn sqlite_feedback_logs_match_feedback_formatter_shape() { + let codex_home = + std::env::temp_dir().join(format!("codex-state-log-db-{}", Uuid::new_v4())); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + let writer = SharedWriter::default(); + + let subscriber = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .with_writer(writer.clone()) + .without_time() + .with_ansi(false) + .with_target(false) + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .with( + start(runtime.clone()) + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ); + let guard = subscriber.set_default(); + + tracing::trace!("threadless-before"); + tracing::info_span!("feedback-thread", thread_id = "thread-1").in_scope(|| { + tracing::info!("thread-scoped"); + }); + tracing::debug!("threadless-after"); + + drop(guard); + + // TODO(ccunningham): Store enough span metadata in SQLite to reproduce span + // prefixes like `feedback-thread{thread_id="thread-1"}:` in feedback exports. + let feedback_logs = writer + .snapshot() + .replace("feedback-thread{thread_id=\"thread-1\"}: ", ""); + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let sqlite_logs = String::from_utf8( + runtime + .query_feedback_logs("thread-1") + .await + .expect("query feedback logs"), + ) + .expect("valid utf-8"); + if sqlite_logs == feedback_logs { + break; + } + assert!( + Instant::now() < deadline, + "sqlite feedback logs did not match feedback formatter output before timeout\nsqlite:\n{sqlite_logs}\nfeedback:\n{feedback_logs}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn flush_persists_logs_for_query() { + let codex_home = + std::env::temp_dir().join(format!("codex-state-log-db-{}", Uuid::new_v4())); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + let layer = start(runtime.clone()); + + let guard = tracing_subscriber::registry() + .with( + layer + .clone() + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .set_default(); + + tracing::info!("buffered-log"); + + layer.flush().await; + drop(guard); + + let after_flush = runtime + .query_logs(&crate::LogQuery::default()) + .await + .expect("query logs after flush"); + assert_eq!(after_flush.len(), 1); + assert_eq!(after_flush[0].message.as_deref(), Some("buffered-log")); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } +} diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 762f68f1a9..2ee8940afd 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -285,6 +285,67 @@ WHERE id IN ( Ok(rows) } + /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget. + pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result> { + let max_bytes = LOG_PARTITION_SIZE_LIMIT_BYTES; + let lines = sqlx::query_scalar::<_, String>( + r#" +WITH latest_process AS ( + SELECT process_uuid + FROM logs + WHERE thread_id = ? AND process_uuid IS NOT NULL + ORDER BY ts DESC, ts_nanos DESC, id DESC + LIMIT 1 +), +feedback_logs AS ( + SELECT + printf('%5s %s', level, message) || CASE + WHEN substr(message, -1, 1) = char(10) THEN '' + ELSE char(10) + END AS line, + length(CAST( + printf('%5s %s', level, message) || CASE + WHEN substr(message, -1, 1) = char(10) THEN '' + ELSE char(10) + END AS BLOB + )) AS line_bytes, + ts, + ts_nanos, + id + FROM logs + WHERE message IS NOT NULL AND ( + thread_id = ? + OR ( + thread_id IS NULL + AND process_uuid IN (SELECT process_uuid FROM latest_process) + ) + ) +) +SELECT line +FROM ( + SELECT + line, + ts, + ts_nanos, + id, + SUM(line_bytes) OVER ( + ORDER BY ts DESC, ts_nanos DESC, id DESC + ) AS cumulative_bytes + FROM feedback_logs +) +WHERE cumulative_bytes <= ? +ORDER BY ts ASC, ts_nanos ASC, id ASC +"#, + ) + .bind(thread_id) + .bind(thread_id) + .bind(max_bytes) + .fetch_all(self.pool.as_ref()) + .await?; + + Ok(lines.concat().into_bytes()) + } + /// Return the max log id matching optional filters. pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result { let mut builder = @@ -712,4 +773,334 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } + + #[tokio::test] + async fn query_feedback_logs_returns_newest_lines_within_limit_in_order() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("alpha".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("bravo".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("charlie".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs("thread-1") + .await + .expect("query feedback logs"); + + assert_eq!( + String::from_utf8(bytes).expect("valid utf-8"), + " INFO alpha\n INFO bravo\n INFO charlie\n" + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_excludes_oversized_newest_row() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + let eleven_mebibytes = "z".repeat(11 * 1024 * 1024); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("small".to_string()), + thread_id: Some("thread-oversized".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(eleven_mebibytes), + thread_id: Some("thread-oversized".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs("thread-oversized") + .await + .expect("query feedback logs"); + + assert_eq!(bytes, Vec::::new()); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_includes_threadless_rows_from_same_process() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-before".to_string()), + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-scoped".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-after".to_string()), + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 4, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("other-process-threadless".to_string()), + thread_id: None, + process_uuid: Some("proc-2".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs("thread-1") + .await + .expect("query feedback logs"); + + assert_eq!( + String::from_utf8(bytes).expect("valid utf-8"), + " INFO threadless-before\n INFO thread-scoped\n INFO threadless-after\n" + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_excludes_threadless_rows_from_prior_processes() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("old-process-threadless".to_string()), + thread_id: None, + process_uuid: Some("proc-old".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("old-process-thread".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-old".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("new-process-thread".to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-new".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 4, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("new-process-threadless".to_string()), + thread_id: None, + process_uuid: Some("proc-new".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs("thread-1") + .await + .expect("query feedback logs"); + + assert_eq!( + String::from_utf8(bytes).expect("valid utf-8"), + " INFO old-process-thread\n INFO new-process-thread\n INFO new-process-threadless\n" + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_keeps_newest_suffix_across_thread_and_threadless_logs() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None) + .await + .expect("initialize runtime"); + let thread_marker = "thread-scoped-oldest"; + let threadless_older_marker = "threadless-older"; + let threadless_newer_marker = "threadless-newer"; + let five_mebibytes = format!("{threadless_older_marker} {}", "a".repeat(5 * 1024 * 1024)); + let four_and_half_mebibytes = format!( + "{threadless_newer_marker} {}", + "b".repeat((9 * 1024 * 1024) / 2) + ); + let one_mebibyte = format!("{thread_marker} {}", "c".repeat(1024 * 1024)); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(one_mebibyte.clone()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(five_mebibytes), + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some(four_and_half_mebibytes), + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs("thread-1") + .await + .expect("query feedback logs"); + let logs = String::from_utf8(bytes).expect("valid utf-8"); + + assert!(!logs.contains(thread_marker)); + assert!(logs.contains(threadless_older_marker)); + assert!(logs.contains(threadless_newer_marker)); + assert_eq!(logs.matches('\n').count(), 2); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } } diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 4144c87f97..33368faa45 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -102,6 +102,7 @@ impl FeedbackNoteView { self.include_logs, &log_file_paths, Some(SessionSource::Cli), + None, ); match result { diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 new file mode 100644 index 0000000000..40328db700 --- /dev/null +++ b/scripts/install/install.ps1 @@ -0,0 +1,196 @@ +param( + [Parameter(Position=0)] + [string]$Version = "latest" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +function Write-Step { + param( + [string]$Message + ) + + Write-Host "==> $Message" +} + +function Normalize-Version { + param( + [string]$RawVersion + ) + + if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") { + return "latest" + } + + if ($RawVersion.StartsWith("rust-v")) { + return $RawVersion.Substring(6) + } + + if ($RawVersion.StartsWith("v")) { + return $RawVersion.Substring(1) + } + + return $RawVersion +} + +function Get-ReleaseUrl { + param( + [string]$AssetName, + [string]$ResolvedVersion + ) + + return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName" +} + +function Path-Contains { + param( + [string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $needle = $Entry.TrimEnd("\") + foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) { + if ($segment.TrimEnd("\") -ieq $needle) { + return $true + } + } + + return $false +} + +function Resolve-Version { + $normalizedVersion = Normalize-Version -RawVersion $Version + if ($normalizedVersion -ne "latest") { + return $normalizedVersion + } + + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/latest" + if (-not $release.tag_name) { + Write-Error "Failed to resolve the latest Codex release version." + exit 1 + } + + return (Normalize-Version -RawVersion $release.tag_name) +} + +if ($env:OS -ne "Windows_NT") { + Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." + exit 1 +} + +if (-not [Environment]::Is64BitOperatingSystem) { + Write-Error "Codex requires a 64-bit version of Windows." + exit 1 +} + +$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture +$target = $null +$platformLabel = $null +$npmTag = $null +switch ($architecture) { + "Arm64" { + $target = "aarch64-pc-windows-msvc" + $platformLabel = "Windows (ARM64)" + $npmTag = "win32-arm64" + } + "X64" { + $target = "x86_64-pc-windows-msvc" + $platformLabel = "Windows (x64)" + $npmTag = "win32-x64" + } + default { + Write-Error "Unsupported architecture: $architecture" + exit 1 + } +} + +if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { + $installDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" +} else { + $installDir = $env:CODEX_INSTALL_DIR +} + +$codexPath = Join-Path $installDir "codex.exe" +$installMode = if (Test-Path $codexPath) { "Updating" } else { "Installing" } + +Write-Step "$installMode Codex CLI" +Write-Step "Detected platform: $platformLabel" + +New-Item -ItemType Directory -Force -Path $installDir | Out-Null + +$resolvedVersion = Resolve-Version +Write-Step "Resolved version: $resolvedVersion" +$packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" + +$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Force -Path $tempDir | Out-Null + +try { + $archivePath = Join-Path $tempDir $packageAsset + $extractDir = Join-Path $tempDir "extract" + $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion + + Write-Step "Downloading Codex CLI" + Invoke-WebRequest -Uri $url -OutFile $archivePath + + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + tar -xzf $archivePath -C $extractDir + + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + Write-Step "Installing to $installDir" + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" + "path/rg.exe" = "rg.exe" + } + + foreach ($relativeSource in $copyMap.Keys) { + $sourcePath = Join-Path $vendorRoot $relativeSource + $destinationPath = Join-Path $installDir $copyMap[$relativeSource] + Move-Item -Force $sourcePath $destinationPath + } +} finally { + Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue +} + +$userPath = [Environment]::GetEnvironmentVariable("Path", "User") +$pathNeedsNewShell = $false +if (-not (Path-Contains -PathValue $userPath -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($userPath)) { + $newUserPath = $installDir + } else { + $newUserPath = "$installDir;$userPath" + } + + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + if (-not (Path-Contains -PathValue $env:Path -Entry $installDir)) { + if ([string]::IsNullOrWhiteSpace($env:Path)) { + $env:Path = $installDir + } else { + $env:Path = "$installDir;$env:Path" + } + } + Write-Step "PATH updated for future PowerShell sessions." + $pathNeedsNewShell = $true +} elseif (Path-Contains -PathValue $env:Path -Entry $installDir) { + Write-Step "$installDir is already on PATH." +} else { + Write-Step "PATH is already configured for future PowerShell sessions." + $pathNeedsNewShell = $true +} + +if ($pathNeedsNewShell) { + Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $installDir) + Write-Step "Or open a new PowerShell window and run: codex" +} else { + Write-Step "Run: codex" +} + +Write-Host "Codex CLI $resolvedVersion installed successfully."