mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Merge branch 'jif/rendering-engine' into jif/add-cli-coloring-for
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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]": {
|
||||
|
||||
Generated
+2
@@ -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\":[]}}",
|
||||
|
||||
Generated
+17
-4
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<string, never>;
|
||||
File diff suppressed because one or more lines are too long
@@ -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<string>, timeoutMs: bigint | null, cwd: string | null, sandboxPolicy: SandboxPolicy | 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, };
|
||||
@@ -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, };
|
||||
@@ -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<EventMsg> | null, rolloutPath: string, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<TextElement>, } } | { "type": "image", "data": { image_url: string, } } | { "type": "localImage", "data": { path: string, } };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<string> | 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<ConversationSummary>, nextCursor: string | 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, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
-5
@@ -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<string, never>;
|
||||
@@ -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<ResponseItem> | null, overrides: NewConversationParams | 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<EventMsg> | null, rolloutPath: string, };
|
||||
@@ -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";
|
||||
@@ -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<AbsolutePathBuf>, networkAccess: boolean | null, excludeTmpdirEnvVar: boolean | null, excludeSlashTmp: boolean | 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<InputItem>, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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<InputItem>, 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, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<EventMsg> | null, rolloutPath: string, };
|
||||
@@ -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, };
|
||||
@@ -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<string, never>;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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 }, };
|
||||
@@ -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";
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -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<GeneratedSchema>) -> Result<Value> {
|
||||
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<GeneratedSchema>) -> Result<Value> {
|
||||
|
||||
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<GeneratedSchema>) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<String> {
|
||||
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<String>) {
|
||||
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<String, Value>,
|
||||
names: HashSet<String>,
|
||||
) -> HashSet<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut to_process: Vec<String> = 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<String> {
|
||||
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<String> = missing.into_iter().collect();
|
||||
missing_names.sort();
|
||||
Err(anyhow!(
|
||||
"{label} schema missing definitions: {}",
|
||||
missing_names.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_missing_definitions(
|
||||
value: &Value,
|
||||
definitions: &Map<String, Value>,
|
||||
missing: &mut HashSet<String>,
|
||||
) {
|
||||
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<String, Value>,
|
||||
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<String, String>) {
|
||||
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<String, String> {
|
||||
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<String> = 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<String> = 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<String> = 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<String> = 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<String> = [
|
||||
"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::<String>::new());
|
||||
let server_notification_methods: BTreeSet<String> =
|
||||
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<String> = [
|
||||
"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::<String>::new());
|
||||
let event_types: BTreeSet<String> = 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<String> = [
|
||||
"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::<String>::new());
|
||||
assert_eq!(
|
||||
output_dir
|
||||
.join("v2")
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<ServiceTier>,
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
pub history_log_id: u64,
|
||||
#[ts(type = "number")]
|
||||
pub history_entry_count: usize,
|
||||
pub initial_messages: Option<Vec<EventMsg>>,
|
||||
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<AuthMode>,
|
||||
}
|
||||
|
||||
@@ -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<NewConversationResponse> {
|
||||
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<AddConversationSubscriptionResponse> {
|
||||
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::<codex_app_server_protocol::RemoveConversationSubscriptionResponse>(
|
||||
request,
|
||||
request_id,
|
||||
"removeConversationListener",
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_user_message(
|
||||
&mut self,
|
||||
conversation_id: &ThreadId,
|
||||
message: &str,
|
||||
) -> Result<SendUserMessageResponse> {
|
||||
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<ThreadStartResponse> {
|
||||
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<LoginChatGptResponse> {
|
||||
fn login_account_chatgpt(&mut self) -> Result<LoginAccountResponse> {
|
||||
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<GetAccountRateLimitsResponse> {
|
||||
@@ -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<LoginChatGptCompleteNotification> {
|
||||
expected_login_id: &str,
|
||||
) -> Result<AccountLoginCompletedNotification> {
|
||||
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<Option<Event>> {
|
||||
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<T>(
|
||||
&mut self,
|
||||
request: ClientRequest,
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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<LogDbLayer>,
|
||||
pub(crate) config_warnings: Vec<ConfigWarningNotification>,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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<Mutex<ThreadState>>,
|
||||
connection_ids: HashSet<ConnectionId>,
|
||||
@@ -140,7 +133,6 @@ impl Default for ThreadEntry {
|
||||
struct ThreadStateManagerInner {
|
||||
live_connections: HashSet<ConnectionId>,
|
||||
threads: HashMap<ThreadId, ThreadEntry>,
|
||||
subscription_state_by_id: HashMap<Uuid, SubscriptionState>,
|
||||
thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,
|
||||
}
|
||||
|
||||
@@ -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<ThreadId> {
|
||||
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<Mutex<ThreadState>> {
|
||||
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);
|
||||
|
||||
@@ -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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
// 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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
self.send_request("getUserSavedConfig", None).await
|
||||
}
|
||||
|
||||
/// Send a `getUserAgent` JSON-RPC request.
|
||||
pub async fn send_get_user_agent_request(&mut self) -> anyhow::Result<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
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<i64> {
|
||||
self.send_request("logoutChatGpt", None).await
|
||||
}
|
||||
|
||||
pub async fn send_config_read_request(
|
||||
&mut self,
|
||||
params: ConfigReadParams,
|
||||
|
||||
@@ -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::<NewConversationResponse>(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::<AddConversationSubscriptionResponse>(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::<SendUserMessageResponse>(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::<ArchiveConversationResponse>(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
|
||||
"#
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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::<NewConversationResponse>(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::<AddConversationSubscriptionResponse>(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::<SendUserMessageResponse>(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::<NewConversationResponse>(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::<AddConversationSubscriptionResponse>(
|
||||
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::<SendUserMessageResponse>(
|
||||
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::<SendUserTurnResponse>(
|
||||
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::<NewConversationResponse>(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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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::<NewConversationResponse>(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::<AddConversationSubscriptionResponse>(
|
||||
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::<SendUserMessageResponse>(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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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::<ForkConversationResponse>(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(())
|
||||
}
|
||||
@@ -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::<NewConversationResponse>(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::<SendUserMessageResponse>(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::<InterruptConversationResponse>(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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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::<ListConversationsResponse>(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::<ListConversationsResponse>(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::<ListConversationsResponse>(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::<ListConversationsResponse>(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::<ListConversationsResponse>(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::<ResumeConversationResponse>(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::<ResumeConversationResponse>(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::<ResumeConversationResponse>(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::<ListConversationsResponse>(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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<NewConversationResponse>(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::<SendUserTurnResponse>(
|
||||
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::<NewConversationResponse>(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::<NewConversationResponse>(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::<SendUserTurnResponse>(
|
||||
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::<SendUserTurnResponse>(
|
||||
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
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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::<SendUserMessageResponse>(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::<SendUserMessageResponse>(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::<ResumeConversationResponse>(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("<model_switch>"))
|
||||
.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("<model_switch>"))
|
||||
.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("<environment_context>")),
|
||||
"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())
|
||||
}
|
||||
@@ -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"
|
||||
"#,
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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<T: DeserializeOwned>(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(())
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<PresentationArtifactResponse, PresentationArtifactError> {
|
||||
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<PresentationArtifactResponse, PresentationArtifactError> {
|
||||
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::<Vec<_>>(),
|
||||
}));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn get_comment_thread(
|
||||
&mut self,
|
||||
request: PresentationArtifactRequest,
|
||||
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
@@ -332,7 +382,7 @@ enum CommentTarget {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct CommentThread {
|
||||
pub(super) struct CommentThread {
|
||||
thread_id: String,
|
||||
target: CommentTarget,
|
||||
position: Option<CommentPosition>,
|
||||
@@ -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<F>(&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<Presentation, PresentationArtifactError> {
|
||||
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<Option<Rect>, 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, "<p:sldSz", "cx").and_then(|value| value.parse::<u32>().ok())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(height) =
|
||||
xml_tag_attribute(&xml, "<p:sldSz", "cy").and_then(|value| value.parse::<u32>().ok())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(Rect::from_emu(0, 0, width, height)))
|
||||
}
|
||||
|
||||
fn zip_entry_string_if_exists<R: Read + Seek>(
|
||||
archive: &mut ZipArchive<R>,
|
||||
path: &str,
|
||||
@@ -1223,7 +1337,11 @@ fn xml_attribute(tag: &str, attribute: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
impl PresentationSlide {
|
||||
fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent {
|
||||
fn to_ppt_rs(
|
||||
&self,
|
||||
slide_size: Rect,
|
||||
document: &PresentationDocument,
|
||||
) -> Result<SlideContent, PresentationArtifactError> {
|
||||
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<F>(&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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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<Mutex<std::collections::HashMap<String, Option<Arc<Font>>>>> =
|
||||
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<Vec<u8>, 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<RgbaImage, PresentationArtifactError> {
|
||||
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::<Vec<_>>();
|
||||
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<Te
|
||||
let mut current_spaces = 0;
|
||||
for glyph in glyphs {
|
||||
if glyph.ch == '\n' {
|
||||
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;
|
||||
continue;
|
||||
@@ -1057,13 +1168,21 @@ fn layout_text_lines(glyphs: &[StyledGlyph], config: TextRenderConfig) -> Vec<Te
|
||||
&& !current.is_empty()
|
||||
&& current_width.saturating_add(glyph_width) > 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<Te
|
||||
current.push(glyph.clone());
|
||||
}
|
||||
if !current.is_empty() || lines.is_empty() {
|
||||
lines.push(TextLine {
|
||||
glyphs: current,
|
||||
width: current_width,
|
||||
spaces: current_spaces,
|
||||
});
|
||||
push_text_line(
|
||||
&mut lines,
|
||||
&mut current,
|
||||
&mut current_width,
|
||||
&mut current_spaces,
|
||||
config.font_px,
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn push_text_line(
|
||||
lines: &mut Vec<TextLine>,
|
||||
current: &mut Vec<StyledGlyph>,
|
||||
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<Arc<Font>> {
|
||||
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<Arc<Font>> {
|
||||
let mut candidates = requested_family
|
||||
.into_iter()
|
||||
.flat_map(preview_font_family_candidates)
|
||||
.collect::<Vec<_>>();
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<Arc<Font>> {
|
||||
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<u8>,
|
||||
) {
|
||||
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<u8> {
|
||||
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<TinyPath, PresentationArtifac
|
||||
polygon_path(&points)
|
||||
}
|
||||
|
||||
fn rounded_rectangle_path(width: f32, height: f32) -> Result<TinyPath, PresentationArtifactError> {
|
||||
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<u8>, 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::<String>(),
|
||||
prefix
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].glyphs.iter().map(|glyph| glyph.ch).collect::<String>(),
|
||||
"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::<String>(),
|
||||
"One strong visual lands the"
|
||||
);
|
||||
assert_eq!(
|
||||
lines[1].glyphs.iter().map(|glyph| glyph.ch).collect::<String>(),
|
||||
"story."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,13 @@ fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn master_layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
|
||||
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<ImagePayload, PresentationArtifactError> {
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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<u32, SpreadsheetArtifactError> {
|
||||
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<T>(
|
||||
id: Option<u32>,
|
||||
map: &BTreeMap<u32, T>,
|
||||
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<String> {
|
||||
match format_id {
|
||||
0 => Some("General".to_string()),
|
||||
@@ -578,3 +676,16 @@ fn builtin_number_format_code(format_id: u32) -> Option<String> {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_number_format_id(format_code: &str) -> Option<u32> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -67,8 +67,6 @@ pub enum ResponseEvent {
|
||||
Completed {
|
||||
response_id: String,
|
||||
token_usage: Option<TokenUsage>,
|
||||
/// 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<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ResponseAppendWsRequest {
|
||||
pub input: Vec<ResponseItem>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_metadata: Option<HashMap<String, String>>,
|
||||
}
|
||||
#[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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user