mirror of
https://github.com/openai/codex.git
synced 2026-06-03 20:02:10 +00:00
Compare commits
16 Commits
image_hint
...
cooper/cod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bb047e329 | ||
|
|
151e2dad06 | ||
|
|
28dbbe6662 | ||
|
|
5bcbe797f5 | ||
|
|
db30a5570c | ||
|
|
19046c4c04 | ||
|
|
d274eda414 | ||
|
|
baf9188c93 | ||
|
|
81ccd4cbf2 | ||
|
|
bf1b28e1c1 | ||
|
|
f6c9720d9f | ||
|
|
eacd9bc5c4 | ||
|
|
bc27ae4419 | ||
|
|
34cb91c747 | ||
|
|
a9a1d9ddd3 | ||
|
|
0321ad1486 |
13
codex-rs/Cargo.lock
generated
13
codex-rs/Cargo.lock
generated
@@ -1928,6 +1928,7 @@ dependencies = [
|
||||
"codex-git-utils",
|
||||
"codex-guardian",
|
||||
"codex-hooks",
|
||||
"codex-http-state",
|
||||
"codex-image-generation-extension",
|
||||
"codex-login",
|
||||
"codex-mcp",
|
||||
@@ -3025,6 +3026,17 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-http-state"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"codex-utils-path",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-image-generation-extension"
|
||||
version = "0.0.0"
|
||||
@@ -3039,7 +3051,6 @@ dependencies = [
|
||||
"codex-model-provider-info",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"http 1.4.0",
|
||||
"pretty_assertions",
|
||||
"schemars 0.8.22",
|
||||
|
||||
@@ -37,6 +37,7 @@ members = [
|
||||
"core-plugins",
|
||||
"core-skills",
|
||||
"hooks",
|
||||
"http-state",
|
||||
"secrets",
|
||||
"exec",
|
||||
"file-system",
|
||||
@@ -178,6 +179,7 @@ codex-file-search = { path = "file-search" }
|
||||
codex-file-watcher = { path = "file-watcher" }
|
||||
codex-git-utils = { path = "git-utils" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
codex-http-state = { path = "http-state" }
|
||||
codex-keyring-store = { path = "keyring-store" }
|
||||
codex-linux-sandbox = { path = "linux-sandbox" }
|
||||
codex-lmstudio = { path = "lmstudio" }
|
||||
|
||||
@@ -1083,6 +1083,24 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateSetParams": {
|
||||
"properties": {
|
||||
"expectedState": {
|
||||
"description": "When present, write only if the calling surface still stores this state.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
@@ -5661,6 +5679,76 @@
|
||||
"title": "ExperimentalFeature/enablement/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/get"
|
||||
],
|
||||
"title": "HttpState/getRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/getRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/set"
|
||||
],
|
||||
"title": "HttpState/setRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/HttpStateSetParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "HttpState/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/clear"
|
||||
],
|
||||
"title": "HttpState/clearRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/clearRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -1573,6 +1573,76 @@
|
||||
"title": "ExperimentalFeature/enablement/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/get"
|
||||
],
|
||||
"title": "HttpState/getRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/getRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/set"
|
||||
],
|
||||
"title": "HttpState/setRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/HttpStateSetParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "HttpState/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/clear"
|
||||
],
|
||||
"title": "HttpState/clearRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/clearRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -10237,6 +10307,57 @@
|
||||
"title": "HooksListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateClearResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "HttpStateClearResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateGetResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "HttpStateGetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateSetParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"expectedState": {
|
||||
"description": "When present, write only if the calling surface still stores this state.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"title": "HttpStateSetParams",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateSetResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"written": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"written"
|
||||
],
|
||||
"title": "HttpStateSetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
|
||||
@@ -2332,6 +2332,76 @@
|
||||
"title": "ExperimentalFeature/enablement/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/get"
|
||||
],
|
||||
"title": "HttpState/getRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/getRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/set"
|
||||
],
|
||||
"title": "HttpState/setRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/HttpStateSetParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "HttpState/setRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"httpState/clear"
|
||||
],
|
||||
"title": "HttpState/clearRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"type": "null"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method"
|
||||
],
|
||||
"title": "HttpState/clearRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -6710,6 +6780,57 @@
|
||||
"title": "HooksListResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateClearResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "HttpStateClearResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateGetResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "HttpStateGetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateSetParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"expectedState": {
|
||||
"description": "When present, write only if the calling surface still stores this state.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"title": "HttpStateSetParams",
|
||||
"type": "object"
|
||||
},
|
||||
"HttpStateSetResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"written": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"written"
|
||||
],
|
||||
"title": "HttpStateSetResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ImageDetail": {
|
||||
"enum": [
|
||||
"auto",
|
||||
|
||||
5
codex-rs/app-server-protocol/schema/json/v2/HttpStateClearResponse.json
generated
Normal file
5
codex-rs/app-server-protocol/schema/json/v2/HttpStateClearResponse.json
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "HttpStateClearResponse",
|
||||
"type": "object"
|
||||
}
|
||||
13
codex-rs/app-server-protocol/schema/json/v2/HttpStateGetResponse.json
generated
Normal file
13
codex-rs/app-server-protocol/schema/json/v2/HttpStateGetResponse.json
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"state": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"title": "HttpStateGetResponse",
|
||||
"type": "object"
|
||||
}
|
||||
20
codex-rs/app-server-protocol/schema/json/v2/HttpStateSetParams.json
generated
Normal file
20
codex-rs/app-server-protocol/schema/json/v2/HttpStateSetParams.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"expectedState": {
|
||||
"description": "When present, write only if the calling surface still stores this state.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"state": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"state"
|
||||
],
|
||||
"title": "HttpStateSetParams",
|
||||
"type": "object"
|
||||
}
|
||||
13
codex-rs/app-server-protocol/schema/json/v2/HttpStateSetResponse.json
generated
Normal file
13
codex-rs/app-server-protocol/schema/json/v2/HttpStateSetResponse.json
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"written": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"written"
|
||||
],
|
||||
"title": "HttpStateSetResponse",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateClearResponse.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateClearResponse.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HttpStateClearResponse = Record<string, never>;
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateGetResponse.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateGetResponse.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HttpStateGetResponse = { state: string | null, };
|
||||
9
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateSetParams.ts
generated
Normal file
9
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateSetParams.ts
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
// 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 HttpStateSetParams = { state: string,
|
||||
/**
|
||||
* When present, write only if the calling surface still stores this state.
|
||||
*/
|
||||
expectedState?: string | null, };
|
||||
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateSetResponse.ts
generated
Normal file
5
codex-rs/app-server-protocol/schema/typescript/v2/HttpStateSetResponse.ts
generated
Normal file
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type HttpStateSetResponse = { written: boolean, };
|
||||
@@ -167,6 +167,10 @@ export type { HookTrustStatus } from "./HookTrustStatus";
|
||||
export type { HooksListEntry } from "./HooksListEntry";
|
||||
export type { HooksListParams } from "./HooksListParams";
|
||||
export type { HooksListResponse } from "./HooksListResponse";
|
||||
export type { HttpStateClearResponse } from "./HttpStateClearResponse";
|
||||
export type { HttpStateGetResponse } from "./HttpStateGetResponse";
|
||||
export type { HttpStateSetParams } from "./HttpStateSetParams";
|
||||
export type { HttpStateSetResponse } from "./HttpStateSetResponse";
|
||||
export type { ItemCompletedNotification } from "./ItemCompletedNotification";
|
||||
export type { ItemGuardianApprovalReviewCompletedNotification } from "./ItemGuardianApprovalReviewCompletedNotification";
|
||||
export type { ItemGuardianApprovalReviewStartedNotification } from "./ItemGuardianApprovalReviewStartedNotification";
|
||||
|
||||
@@ -861,6 +861,21 @@ client_request_definitions! {
|
||||
serialization: global("remote-control-clients"),
|
||||
response: v2::RemoteControlClientsRevokeResponse,
|
||||
},
|
||||
HttpStateGet => "httpState/get" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: global_shared_read("http-state"),
|
||||
response: v2::HttpStateGetResponse,
|
||||
},
|
||||
HttpStateSet => "httpState/set" {
|
||||
params: v2::HttpStateSetParams,
|
||||
serialization: global("http-state"),
|
||||
response: v2::HttpStateSetResponse,
|
||||
},
|
||||
HttpStateClear => "httpState/clear" {
|
||||
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
||||
serialization: global("http-state"),
|
||||
response: v2::HttpStateClearResponse,
|
||||
},
|
||||
#[experimental("collaborationMode/list")]
|
||||
/// Lists collaboration mode presets.
|
||||
CollaborationModeList => "collaborationMode/list" {
|
||||
|
||||
33
codex-rs/app-server-protocol/src/protocol/v2/http_state.rs
Normal file
33
codex-rs/app-server-protocol/src/protocol/v2/http_state.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use ts_rs::TS;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct HttpStateGetResponse {
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct HttpStateSetParams {
|
||||
pub state: String,
|
||||
/// When present, write only if the calling surface still stores this state.
|
||||
#[ts(optional = nullable)]
|
||||
pub expected_state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct HttpStateSetResponse {
|
||||
pub written: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct HttpStateClearResponse {}
|
||||
@@ -11,6 +11,7 @@ mod experimental_feature;
|
||||
mod feedback;
|
||||
mod fs;
|
||||
mod hook;
|
||||
mod http_state;
|
||||
mod item;
|
||||
mod mcp;
|
||||
mod model;
|
||||
@@ -37,6 +38,7 @@ pub use experimental_feature::*;
|
||||
pub use feedback::*;
|
||||
pub use fs::*;
|
||||
pub use hook::*;
|
||||
pub use http_state::*;
|
||||
pub use item::*;
|
||||
pub use mcp::*;
|
||||
pub use model::*;
|
||||
|
||||
@@ -53,6 +53,7 @@ codex-utils-pty = { workspace = true }
|
||||
codex-backend-client = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-chatgpt = { workspace = true }
|
||||
codex-http-state = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-image-generation-extension = { workspace = true }
|
||||
codex-memories-extension = { workspace = true }
|
||||
|
||||
@@ -214,6 +214,9 @@ Example with notification opt-out:
|
||||
- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled.
|
||||
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
|
||||
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
|
||||
- `httpState/get` — read the HTTP state file for the initialized client's recognized native surface. Returns `{ state }`, where `state` is `null` when no file exists.
|
||||
- `httpState/set` — write the initialized client's recognized native-surface HTTP state file. Pass `expectedState` to write only when the file still contains that value; omit it to replace the file unconditionally. Returns `{ written }`.
|
||||
- `httpState/clear` — delete the HTTP state file for the initialized client's recognized native surface. Returns `{}`.
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::request_processors::ExternalAgentConfigRequestProcessor;
|
||||
use crate::request_processors::FeedbackRequestProcessor;
|
||||
use crate::request_processors::FsRequestProcessor;
|
||||
use crate::request_processors::GitRequestProcessor;
|
||||
use crate::request_processors::HttpStateRequestProcessor;
|
||||
use crate::request_processors::InitializeRequestProcessor;
|
||||
use crate::request_processors::MarketplaceRequestProcessor;
|
||||
use crate::request_processors::McpRequestProcessor;
|
||||
@@ -176,6 +177,7 @@ pub(crate) struct MessageProcessor {
|
||||
initialize_processor: InitializeRequestProcessor,
|
||||
marketplace_processor: MarketplaceRequestProcessor,
|
||||
mcp_processor: McpRequestProcessor,
|
||||
http_state_processor: HttpStateRequestProcessor,
|
||||
plugin_processor: PluginRequestProcessor,
|
||||
remote_control_processor: RemoteControlRequestProcessor,
|
||||
search_processor: SearchRequestProcessor,
|
||||
@@ -400,6 +402,7 @@ impl MessageProcessor {
|
||||
outgoing.clone(),
|
||||
config_manager.clone(),
|
||||
);
|
||||
let http_state_processor = HttpStateRequestProcessor::new(config.codex_home.to_path_buf());
|
||||
let plugin_processor = PluginRequestProcessor::new(
|
||||
auth_manager.clone(),
|
||||
Arc::clone(&thread_manager),
|
||||
@@ -503,6 +506,7 @@ impl MessageProcessor {
|
||||
initialize_processor,
|
||||
marketplace_processor,
|
||||
mcp_processor,
|
||||
http_state_processor,
|
||||
plugin_processor,
|
||||
remote_control_processor,
|
||||
search_processor,
|
||||
@@ -932,6 +936,18 @@ impl MessageProcessor {
|
||||
.clients_revoke(params)
|
||||
.await
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::HttpStateGet { .. } => self
|
||||
.http_state_processor
|
||||
.get(app_server_client_name.as_deref())
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::HttpStateSet { params, .. } => self
|
||||
.http_state_processor
|
||||
.set(app_server_client_name.as_deref(), params)
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::HttpStateClear { .. } => self
|
||||
.http_state_processor
|
||||
.clear(app_server_client_name.as_deref())
|
||||
.map(|response| Some(response.into())),
|
||||
ClientRequest::ConfigRequirementsRead { params: _, .. } => self
|
||||
.config_processor
|
||||
.config_requirements_read()
|
||||
|
||||
@@ -463,6 +463,7 @@ mod feedback_doctor_report;
|
||||
mod feedback_processor;
|
||||
mod fs_processor;
|
||||
mod git_processor;
|
||||
mod http_state_processor;
|
||||
mod initialize_processor;
|
||||
mod marketplace_processor;
|
||||
mod mcp_processor;
|
||||
@@ -485,6 +486,7 @@ pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProces
|
||||
pub(crate) use feedback_processor::FeedbackRequestProcessor;
|
||||
pub(crate) use fs_processor::FsRequestProcessor;
|
||||
pub(crate) use git_processor::GitRequestProcessor;
|
||||
pub(crate) use http_state_processor::HttpStateRequestProcessor;
|
||||
pub(crate) use initialize_processor::InitializeRequestProcessor;
|
||||
pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
|
||||
pub(crate) use mcp_processor::McpRequestProcessor;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::HttpStateClearResponse;
|
||||
use codex_app_server_protocol::HttpStateGetResponse;
|
||||
use codex_app_server_protocol::HttpStateSetParams;
|
||||
use codex_app_server_protocol::HttpStateSetResponse;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_http_state::HttpStateStore;
|
||||
use codex_http_state::HttpStateSurface;
|
||||
|
||||
use crate::error_code::internal_error;
|
||||
use crate::error_code::invalid_request;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct HttpStateRequestProcessor {
|
||||
store: HttpStateStore,
|
||||
}
|
||||
|
||||
impl HttpStateRequestProcessor {
|
||||
pub(crate) fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
store: HttpStateStore::new(codex_home),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<HttpStateGetResponse, JSONRPCErrorError> {
|
||||
let surface = surface_for_client_name(app_server_client_name)?;
|
||||
let state = self.store.get(surface).map_err(map_get_error)?;
|
||||
Ok(HttpStateGetResponse { state })
|
||||
}
|
||||
|
||||
pub(crate) fn set(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
params: HttpStateSetParams,
|
||||
) -> Result<HttpStateSetResponse, JSONRPCErrorError> {
|
||||
let surface = surface_for_client_name(app_server_client_name)?;
|
||||
let written = match params.expected_state {
|
||||
Some(expected_state) => {
|
||||
self.store
|
||||
.compare_and_set(surface, &expected_state, params.state)
|
||||
}
|
||||
None => self.store.set(surface, params.state).map(|()| true),
|
||||
}
|
||||
.map_err(map_set_error)?;
|
||||
Ok(HttpStateSetResponse { written })
|
||||
}
|
||||
|
||||
pub(crate) fn clear(
|
||||
&self,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<HttpStateClearResponse, JSONRPCErrorError> {
|
||||
let surface = surface_for_client_name(app_server_client_name)?;
|
||||
self.store.clear(surface).map_err(map_clear_error)?;
|
||||
Ok(HttpStateClearResponse {})
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_for_client_name(
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> Result<HttpStateSurface, JSONRPCErrorError> {
|
||||
let client_name = app_server_client_name
|
||||
.ok_or_else(|| invalid_request("HTTP state requires an initialized client"))?;
|
||||
HttpStateSurface::try_from_app_server_client_name(client_name).ok_or_else(|| {
|
||||
invalid_request(format!(
|
||||
"HTTP state is unavailable for app-server client {client_name:?}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn map_get_error(error: io::Error) -> JSONRPCErrorError {
|
||||
internal_error(format!("failed to read HTTP state: {error}"))
|
||||
}
|
||||
|
||||
fn map_set_error(error: io::Error) -> JSONRPCErrorError {
|
||||
internal_error(format!("failed to write HTTP state: {error}"))
|
||||
}
|
||||
|
||||
fn map_clear_error(error: io::Error) -> JSONRPCErrorError {
|
||||
internal_error(format!("failed to clear HTTP state: {error}"))
|
||||
}
|
||||
177
codex-rs/app-server/tests/suite/v2/http_state.rs
Normal file
177
codex-rs/app-server/tests/suite/v2/http_state.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use codex_app_server_protocol::ClientInfo;
|
||||
use codex_app_server_protocol::HttpStateClearResponse;
|
||||
use codex_app_server_protocol::HttpStateGetResponse;
|
||||
use codex_app_server_protocol::HttpStateSetParams;
|
||||
use codex_app_server_protocol::HttpStateSetResponse;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
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_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STATE_N: &str = "state-n";
|
||||
const STATE_N_PLUS_ONE: &str = "state-n-plus-one";
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_state_bridge_reads_writes_rotates_and_clears_surface_state() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
let initialized = mcp
|
||||
.initialize_with_capabilities(client_info("codex_desktop_ssh"), /*capabilities*/ None)
|
||||
.await?;
|
||||
let JSONRPCMessage::Response(_) = initialized else {
|
||||
anyhow::bail!("expected initialize response, got {initialized:?}");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
get_state(&mut mcp).await?,
|
||||
HttpStateGetResponse { state: None }
|
||||
);
|
||||
assert_eq!(
|
||||
set_state(
|
||||
&mut mcp,
|
||||
HttpStateSetParams {
|
||||
state: STATE_N.to_string(),
|
||||
expected_state: None,
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
HttpStateSetResponse { written: true }
|
||||
);
|
||||
assert_eq!(
|
||||
get_state(&mut mcp).await?,
|
||||
HttpStateGetResponse {
|
||||
state: Some(STATE_N.to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
set_state(
|
||||
&mut mcp,
|
||||
HttpStateSetParams {
|
||||
state: STATE_N_PLUS_ONE.to_string(),
|
||||
expected_state: Some("stale-state".to_string()),
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
HttpStateSetResponse { written: false }
|
||||
);
|
||||
assert_eq!(
|
||||
get_state(&mut mcp).await?,
|
||||
HttpStateGetResponse {
|
||||
state: Some(STATE_N.to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
set_state(
|
||||
&mut mcp,
|
||||
HttpStateSetParams {
|
||||
state: STATE_N_PLUS_ONE.to_string(),
|
||||
expected_state: Some(STATE_N.to_string()),
|
||||
},
|
||||
)
|
||||
.await?,
|
||||
HttpStateSetResponse { written: true }
|
||||
);
|
||||
assert_eq!(
|
||||
get_state(&mut mcp).await?,
|
||||
HttpStateGetResponse {
|
||||
state: Some(STATE_N_PLUS_ONE.to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("httpState/clear", /*params*/ None)
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
to_response::<HttpStateClearResponse>(response)?,
|
||||
HttpStateClearResponse {}
|
||||
);
|
||||
assert_eq!(
|
||||
get_state(&mut mcp).await?,
|
||||
HttpStateGetResponse { state: None }
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("state/codex_desktop_ssh.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_state_bridge_rejects_unknown_client_surface() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
let initialized = mcp
|
||||
.initialize_with_client_info(client_info("third_party_client"))
|
||||
.await?;
|
||||
let JSONRPCMessage::Response(_) = initialized else {
|
||||
anyhow::bail!("expected initialize response, got {initialized:?}");
|
||||
};
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request("httpState/get", /*params*/ None)
|
||||
.await?;
|
||||
let error = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(error.error.code, -32600);
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"HTTP state is unavailable for app-server client \"third_party_client\""
|
||||
);
|
||||
assert_eq!(error.error.data, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_state(mcp: &mut TestAppServer) -> Result<HttpStateGetResponse> {
|
||||
let request_id = mcp
|
||||
.send_raw_request("httpState/get", /*params*/ None)
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
async fn set_state(
|
||||
mcp: &mut TestAppServer,
|
||||
params: HttpStateSetParams,
|
||||
) -> Result<HttpStateSetResponse> {
|
||||
let request_id = mcp
|
||||
.send_raw_request("httpState/set", Some(serde_json::to_value(params)?))
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
to_response(response)
|
||||
}
|
||||
|
||||
fn client_info(name: &str) -> ClientInfo {
|
||||
ClientInfo {
|
||||
name: name.to_string(),
|
||||
title: None,
|
||||
version: "1.0.0".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Result<()> {
|
||||
async fn standalone_image_generation_persists_image_and_returns_it_to_model() -> Result<()> {
|
||||
let call_id = "image-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_image_response(&server).await;
|
||||
@@ -124,13 +124,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
let output_hint = output["output"][1]["text"]
|
||||
.as_str()
|
||||
.context("image output should include model-visible path hint")?;
|
||||
assert!(
|
||||
output_hint.contains(&saved_path.display().to_string()),
|
||||
"output hint should identify the path core saved"
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(1));
|
||||
assert!(
|
||||
!requests[1]
|
||||
.message_input_texts("developer")
|
||||
@@ -162,7 +156,7 @@ const result = await tools.image_gen__imagegen({
|
||||
action: "generate",
|
||||
prompt: "paint a blue whale",
|
||||
});
|
||||
generatedImage(result);
|
||||
image(result);
|
||||
"#,
|
||||
),
|
||||
responses::ev_completed("resp-1"),
|
||||
@@ -209,12 +203,7 @@ generatedImage(result);
|
||||
"detail": "high",
|
||||
})
|
||||
);
|
||||
assert!(
|
||||
output["output"][2]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("Generated images are saved"))
|
||||
);
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(output["output"].as_array().map(Vec::len), Some(2));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ mod experimental_feature_list;
|
||||
mod external_agent_config;
|
||||
mod fs;
|
||||
mod hooks_list;
|
||||
mod http_state;
|
||||
mod imagegen_extension;
|
||||
mod initialize;
|
||||
mod marketplace_add;
|
||||
|
||||
@@ -25,7 +25,6 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
|
||||
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
|
||||
- `generatedImage(result: { image_url: string; output_hint?: string })`: Appends an image-generation result and its optional output hint.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
|
||||
@@ -129,58 +129,6 @@ pub(super) fn image_callback(
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
pub(super) fn generated_image_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
let value = if args.length() == 0 {
|
||||
v8::undefined(scope).into()
|
||||
} else {
|
||||
args.get(0)
|
||||
};
|
||||
let output_hint = match generated_image_output_hint(scope, value) {
|
||||
Ok(output_hint) => output_hint,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let image_item = match normalize_output_image(scope, value, /*detail_override*/ None) {
|
||||
Ok(image_item) => image_item,
|
||||
Err(()) => return,
|
||||
};
|
||||
if let Some(state) = scope.get_slot::<RuntimeState>() {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(image_item));
|
||||
if let Some(text) = output_hint {
|
||||
let _ = state.event_tx.send(RuntimeEvent::ContentItem(
|
||||
FunctionCallOutputContentItem::InputText { text },
|
||||
));
|
||||
}
|
||||
}
|
||||
retval.set(v8::undefined(scope).into());
|
||||
}
|
||||
|
||||
fn generated_image_output_hint(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
value: v8::Local<'_, v8::Value>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let object = v8::Local::<v8::Object>::try_from(value)
|
||||
.map_err(|_| "generatedImage expects an image generation result object".to_string())?;
|
||||
let key = v8::String::new(scope, "output_hint")
|
||||
.ok_or_else(|| "failed to allocate generatedImage helper keys".to_string())?;
|
||||
let output_hint = object
|
||||
.get(scope, key.into())
|
||||
.ok_or_else(|| "failed to read generatedImage output_hint".to_string())?;
|
||||
if output_hint.is_undefined() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output_hint.is_string() {
|
||||
return Err("generatedImage output_hint must be a string when provided".to_string());
|
||||
}
|
||||
Ok(Some(output_hint.to_rust_string_lossy(scope)))
|
||||
}
|
||||
|
||||
pub(super) fn store_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use super::RuntimeState;
|
||||
use super::callbacks::clear_timeout_callback;
|
||||
use super::callbacks::exit_callback;
|
||||
use super::callbacks::generated_image_callback;
|
||||
use super::callbacks::image_callback;
|
||||
use super::callbacks::load_callback;
|
||||
use super::callbacks::notify_callback;
|
||||
@@ -24,7 +23,6 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
|
||||
let text = helper_function(scope, "text", text_callback)?;
|
||||
let image = helper_function(scope, "image", image_callback)?;
|
||||
let generated_image = helper_function(scope, "generatedImage", generated_image_callback)?;
|
||||
let store = helper_function(scope, "store", store_callback)?;
|
||||
let load = helper_function(scope, "load", load_callback)?;
|
||||
let notify = helper_function(scope, "notify", notify_callback)?;
|
||||
@@ -37,7 +35,6 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
set_global(scope, global, "setTimeout", set_timeout.into())?;
|
||||
set_global(scope, global, "text", text.into())?;
|
||||
set_global(scope, global, "image", image.into())?;
|
||||
set_global(scope, global, "generatedImage", generated_image.into())?;
|
||||
set_global(scope, global, "store", store.into())?;
|
||||
set_global(scope, global, "load", load.into())?;
|
||||
set_global(scope, global, "notify", notify.into())?;
|
||||
|
||||
@@ -1563,44 +1563,6 @@ image({
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_image_helper_appends_image_and_output_hint() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
let response = execute(
|
||||
&service,
|
||||
ExecuteRequest {
|
||||
source: r#"
|
||||
generatedImage({
|
||||
image_url: "https://example.com/image.jpg",
|
||||
output_hint: "generated image save hint",
|
||||
});
|
||||
"#
|
||||
.to_string(),
|
||||
yield_time_ms: None,
|
||||
..execute_request("")
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
RuntimeResponse::Result {
|
||||
cell_id: cell_id("1"),
|
||||
content_items: vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "generated image save hint".to_string(),
|
||||
},
|
||||
],
|
||||
error_text: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_helper_second_arg_overrides_explicit_object_detail() {
|
||||
let service = CodeModeService::new();
|
||||
|
||||
@@ -41,6 +41,27 @@ pub trait AuthProvider: Send + Sync {
|
||||
headers
|
||||
}
|
||||
|
||||
/// Adds auth headers for a concrete HTTP request URL.
|
||||
///
|
||||
/// URL-sensitive providers can override this to scope request-specific
|
||||
/// headers without exposing them through generic/non-HTTP auth helpers.
|
||||
fn add_auth_headers_for_url(&self, _request_url: &str, headers: &mut HeaderMap) {
|
||||
self.add_auth_headers(headers);
|
||||
}
|
||||
|
||||
/// Observes response headers for auth state that may need to rotate after a request.
|
||||
///
|
||||
/// Most providers do not need this. Providers with server-minted,
|
||||
/// request-scoped state may validate the URL and selectively persist
|
||||
/// response headers here.
|
||||
fn observe_response_headers(
|
||||
&self,
|
||||
_request_url: &str,
|
||||
_request_headers: &HeaderMap,
|
||||
_response_headers: &HeaderMap,
|
||||
) {
|
||||
}
|
||||
|
||||
/// Applies auth to a complete outbound request and returns the request to send.
|
||||
///
|
||||
/// The input `request` is moved into this method. Implementations may mutate
|
||||
@@ -54,7 +75,7 @@ pub trait AuthProvider: Send + Sync {
|
||||
/// If this returns [`AuthError`], the request should not be sent.
|
||||
async fn apply_auth(&self, request: Request) -> Result<Request, AuthError> {
|
||||
let mut request = request;
|
||||
self.add_auth_headers(&mut request.headers);
|
||||
self.add_auth_headers_for_url(&request.url, &mut request.headers);
|
||||
Ok(request)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,16 @@ impl<T: HttpTransport> EndpointSession<T> {
|
||||
let transport = &self.transport;
|
||||
async move {
|
||||
let req = auth.apply_auth(req).await.map_err(TransportError::from)?;
|
||||
transport.execute(req).await
|
||||
let request_url = req.url.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
let response = transport.execute(req).await;
|
||||
observe_auth_response_headers(
|
||||
auth.as_ref(),
|
||||
&request_url,
|
||||
&request_headers,
|
||||
&response,
|
||||
);
|
||||
response
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -143,7 +152,16 @@ impl<T: HttpTransport> EndpointSession<T> {
|
||||
let transport = &self.transport;
|
||||
async move {
|
||||
let req = auth.apply_auth(req).await.map_err(TransportError::from)?;
|
||||
transport.stream(req).await
|
||||
let request_url = req.url.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
let response = transport.stream(req).await;
|
||||
observe_auth_response_headers(
|
||||
auth.as_ref(),
|
||||
&request_url,
|
||||
&request_headers,
|
||||
&response,
|
||||
);
|
||||
response
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -152,3 +170,44 @@ impl<T: HttpTransport> EndpointSession<T> {
|
||||
Ok(stream)
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_auth_response_headers<T>(
|
||||
auth: &dyn crate::auth::AuthProvider,
|
||||
request_url: &str,
|
||||
request_headers: &HeaderMap,
|
||||
response: &Result<T, TransportError>,
|
||||
) where
|
||||
T: ResponseHeaders,
|
||||
{
|
||||
match response {
|
||||
Ok(response) => {
|
||||
auth.observe_response_headers(request_url, request_headers, response.headers())
|
||||
}
|
||||
Err(TransportError::Http {
|
||||
headers: Some(headers),
|
||||
..
|
||||
}) => auth.observe_response_headers(request_url, request_headers, headers),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposes transport response headers so auth providers can observe request-scoped updates.
|
||||
trait ResponseHeaders {
|
||||
fn headers(&self) -> &HeaderMap;
|
||||
}
|
||||
|
||||
impl ResponseHeaders for Response {
|
||||
fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseHeaders for StreamResponse {
|
||||
fn headers(&self) -> &HeaderMap {
|
||||
&self.headers
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
174
codex-rs/codex-api/src/endpoint/session_tests.rs
Normal file
174
codex-rs/codex-api/src/endpoint/session_tests.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use super::*;
|
||||
use crate::provider::RetryConfig;
|
||||
use async_trait::async_trait;
|
||||
use codex_client::Request;
|
||||
use http::HeaderValue;
|
||||
use http::StatusCode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const RESPONSES_URL: &str = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingAuthProvider {
|
||||
observed_headers: StdMutex<Vec<(String, HeaderMap, HeaderMap)>>,
|
||||
}
|
||||
|
||||
impl crate::auth::AuthProvider for RecordingAuthProvider {
|
||||
fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
|
||||
|
||||
fn add_auth_headers_for_url(&self, _request_url: &str, headers: &mut HeaderMap) {
|
||||
headers.insert("x-test-state", HeaderValue::from_static("sent-state"));
|
||||
}
|
||||
|
||||
fn observe_response_headers(
|
||||
&self,
|
||||
request_url: &str,
|
||||
request_headers: &HeaderMap,
|
||||
response_headers: &HeaderMap,
|
||||
) {
|
||||
self.observed_headers
|
||||
.lock()
|
||||
.expect("recording auth lock should not be poisoned")
|
||||
.push((
|
||||
request_url.to_string(),
|
||||
request_headers.clone(),
|
||||
response_headers.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RejectingTransport {
|
||||
requests: Arc<StdMutex<Vec<(String, HeaderMap)>>>,
|
||||
response_headers: HeaderMap,
|
||||
}
|
||||
|
||||
impl RejectingTransport {
|
||||
fn new(response_headers: HeaderMap) -> Self {
|
||||
Self {
|
||||
requests: Arc::new(StdMutex::new(Vec::new())),
|
||||
response_headers,
|
||||
}
|
||||
}
|
||||
|
||||
fn http_error(&self, request: Request) -> TransportError {
|
||||
self.requests
|
||||
.lock()
|
||||
.expect("recording transport lock should not be poisoned")
|
||||
.push((request.url.clone(), request.headers));
|
||||
TransportError::Http {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
url: Some(request.url),
|
||||
headers: Some(self.response_headers.clone()),
|
||||
body: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HttpTransport for RejectingTransport {
|
||||
async fn execute(&self, request: Request) -> Result<Response, TransportError> {
|
||||
Err(self.http_error(request))
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<StreamResponse, TransportError> {
|
||||
Err(self.http_error(request))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execute_attaches_url_scoped_auth_and_observes_http_error_headers() {
|
||||
let (session, transport, auth, request_headers, response_headers) = test_session();
|
||||
|
||||
session
|
||||
.execute(
|
||||
Method::POST,
|
||||
"responses",
|
||||
HeaderMap::new(),
|
||||
/*body*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("request should fail");
|
||||
|
||||
assert_request_and_response_headers(&transport, &auth, request_headers, response_headers);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_attaches_url_scoped_auth_and_observes_http_error_headers() {
|
||||
let (session, transport, auth, request_headers, response_headers) = test_session();
|
||||
|
||||
let result = session
|
||||
.stream_with(
|
||||
Method::POST,
|
||||
"responses",
|
||||
HeaderMap::new(),
|
||||
/*body*/ None,
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "request should fail");
|
||||
|
||||
assert_request_and_response_headers(&transport, &auth, request_headers, response_headers);
|
||||
}
|
||||
|
||||
fn test_session() -> (
|
||||
EndpointSession<RejectingTransport>,
|
||||
RejectingTransport,
|
||||
Arc<RecordingAuthProvider>,
|
||||
HeaderMap,
|
||||
HeaderMap,
|
||||
) {
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert("x-test-state", HeaderValue::from_static("sent-state"));
|
||||
let mut response_headers = HeaderMap::new();
|
||||
response_headers.insert(
|
||||
"x-test-state-update",
|
||||
HeaderValue::from_static("rotated-state"),
|
||||
);
|
||||
let transport = RejectingTransport::new(response_headers.clone());
|
||||
let auth = Arc::new(RecordingAuthProvider::default());
|
||||
let session = EndpointSession::new(transport.clone(), provider(), auth.clone());
|
||||
(session, transport, auth, request_headers, response_headers)
|
||||
}
|
||||
|
||||
fn provider() -> Provider {
|
||||
Provider {
|
||||
name: "test".to_string(),
|
||||
base_url: "https://chatgpt.com/backend-api/codex".to_string(),
|
||||
query_params: None,
|
||||
headers: HeaderMap::new(),
|
||||
retry: RetryConfig {
|
||||
max_attempts: 1,
|
||||
base_delay: Duration::from_millis(1),
|
||||
retry_429: false,
|
||||
retry_5xx: false,
|
||||
retry_transport: false,
|
||||
},
|
||||
stream_idle_timeout: Duration::from_secs(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_request_and_response_headers(
|
||||
transport: &RejectingTransport,
|
||||
auth: &RecordingAuthProvider,
|
||||
request_headers: HeaderMap,
|
||||
response_headers: HeaderMap,
|
||||
) {
|
||||
assert_eq!(
|
||||
transport
|
||||
.requests
|
||||
.lock()
|
||||
.expect("recording transport lock should not be poisoned")
|
||||
.as_slice(),
|
||||
&[(RESPONSES_URL.to_string(), request_headers.clone())]
|
||||
);
|
||||
assert_eq!(
|
||||
auth.observed_headers
|
||||
.lock()
|
||||
.expect("recording auth lock should not be poisoned")
|
||||
.as_slice(),
|
||||
&[(RESPONSES_URL.to_string(), request_headers, response_headers,)]
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,9 @@ pub async fn upload_local_file(
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let create_url = format!("{}/files", base_url.trim_end_matches('/'));
|
||||
let create_response = authorized_request(auth, reqwest::Method::POST, &create_url)
|
||||
let (create_request, create_request_headers) =
|
||||
authorized_request(auth, reqwest::Method::POST, &create_url);
|
||||
let create_response = create_request
|
||||
.json(&serde_json::json!({
|
||||
"file_name": file_name,
|
||||
"file_size": metadata.len(),
|
||||
@@ -141,6 +143,11 @@ pub async fn upload_local_file(
|
||||
url: create_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
auth.observe_response_headers(
|
||||
&create_url,
|
||||
&create_request_headers,
|
||||
create_response.headers(),
|
||||
);
|
||||
let create_status = create_response.status();
|
||||
let create_body = create_response.text().await.unwrap_or_default();
|
||||
if !create_status.is_success() {
|
||||
@@ -191,7 +198,9 @@ pub async fn upload_local_file(
|
||||
);
|
||||
let finalize_started_at = Instant::now();
|
||||
loop {
|
||||
let finalize_response = authorized_request(auth, reqwest::Method::POST, &finalize_url)
|
||||
let (finalize_request, finalize_request_headers) =
|
||||
authorized_request(auth, reqwest::Method::POST, &finalize_url);
|
||||
let finalize_response = finalize_request
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await
|
||||
@@ -199,6 +208,11 @@ pub async fn upload_local_file(
|
||||
url: finalize_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
auth.observe_response_headers(
|
||||
&finalize_url,
|
||||
&finalize_request_headers,
|
||||
finalize_response.headers(),
|
||||
);
|
||||
let finalize_status = finalize_response.status();
|
||||
let finalize_body = finalize_response.text().await.unwrap_or_default();
|
||||
if !finalize_status.is_success() {
|
||||
@@ -255,15 +269,18 @@ fn authorized_request(
|
||||
auth: &dyn AuthProvider,
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
) -> (reqwest::RequestBuilder, http::HeaderMap) {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
auth.add_auth_headers(&mut headers);
|
||||
auth.add_auth_headers_for_url(url, &mut headers);
|
||||
|
||||
let client = build_reqwest_client();
|
||||
client
|
||||
.request(method, url)
|
||||
.timeout(OPENAI_FILE_REQUEST_TIMEOUT)
|
||||
.headers(headers)
|
||||
(
|
||||
client
|
||||
.request(method, url)
|
||||
.timeout(OPENAI_FILE_REQUEST_TIMEOUT)
|
||||
.headers(headers.clone()),
|
||||
headers,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_reqwest_client() -> reqwest::Client {
|
||||
@@ -279,6 +296,7 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use reqwest::header::HeaderValue;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::TempDir;
|
||||
@@ -291,8 +309,16 @@ mod tests {
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ChatGptTestAuth;
|
||||
const SENT_STATE: &str = "sent-state";
|
||||
const CREATED_STATE: &str = "created-state";
|
||||
const RETRY_STATE: &str = "retry-state";
|
||||
const FINALIZED_STATE: &str = "finalized-state";
|
||||
type ObservedUpdate = (String, Option<String>);
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct ChatGptTestAuth {
|
||||
observed_updates: Arc<Mutex<Vec<ObservedUpdate>>>,
|
||||
}
|
||||
|
||||
impl AuthProvider for ChatGptTestAuth {
|
||||
fn add_auth_headers(&self, headers: &mut reqwest::header::HeaderMap) {
|
||||
@@ -302,10 +328,33 @@ mod tests {
|
||||
);
|
||||
headers.insert("ChatGPT-Account-ID", HeaderValue::from_static("account_id"));
|
||||
}
|
||||
|
||||
fn add_auth_headers_for_url(&self, _request_url: &str, headers: &mut http::HeaderMap) {
|
||||
self.add_auth_headers(headers);
|
||||
headers.insert("x-test-state", HeaderValue::from_static(SENT_STATE));
|
||||
}
|
||||
|
||||
fn observe_response_headers(
|
||||
&self,
|
||||
request_url: &str,
|
||||
_request_headers: &http::HeaderMap,
|
||||
response_headers: &http::HeaderMap,
|
||||
) {
|
||||
self.observed_updates
|
||||
.lock()
|
||||
.expect("observed updates lock should not be poisoned")
|
||||
.push((
|
||||
request_url.to_string(),
|
||||
response_headers
|
||||
.get("x-test-state-update")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(ToString::to_string),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn chatgpt_auth() -> ChatGptTestAuth {
|
||||
ChatGptTestAuth
|
||||
ChatGptTestAuth::default()
|
||||
}
|
||||
|
||||
fn base_url_for(server: &MockServer) -> String {
|
||||
@@ -318,6 +367,7 @@ mod tests {
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.and(header("x-test-state", SENT_STATE))
|
||||
.and(body_json(serde_json::json!({
|
||||
"file_name": "hello.txt",
|
||||
"file_size": 5,
|
||||
@@ -325,6 +375,7 @@ mod tests {
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("x-test-state-update", CREATED_STATE)
|
||||
.set_body_json(serde_json::json!({"file_id": "file_123", "upload_url": format!("{}/upload/file_123", server.uri())})),
|
||||
)
|
||||
.mount(&server)
|
||||
@@ -340,20 +391,25 @@ mod tests {
|
||||
let download_url = format!("{}/download/file_123", server.uri());
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files/file_123/uploaded"))
|
||||
.and(header("x-test-state", SENT_STATE))
|
||||
.respond_with(move |_request: &Request| {
|
||||
if finalize_attempts_responder.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"status": "retry"
|
||||
}));
|
||||
return ResponseTemplate::new(200)
|
||||
.insert_header("x-test-state-update", RETRY_STATE)
|
||||
.set_body_json(serde_json::json!({
|
||||
"status": "retry"
|
||||
}));
|
||||
}
|
||||
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"status": "success",
|
||||
"download_url": download_url,
|
||||
"file_name": "hello.txt",
|
||||
"mime_type": "text/plain",
|
||||
"file_size_bytes": 5
|
||||
}))
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("x-test-state-update", FINALIZED_STATE)
|
||||
.set_body_json(serde_json::json!({
|
||||
"status": "success",
|
||||
"download_url": download_url,
|
||||
"file_name": "hello.txt",
|
||||
"mime_type": "text/plain",
|
||||
"file_size_bytes": 5
|
||||
}))
|
||||
})
|
||||
.mount(&server)
|
||||
.await;
|
||||
@@ -363,7 +419,8 @@ mod tests {
|
||||
let path = dir.path().join("hello.txt");
|
||||
tokio::fs::write(&path, b"hello").await.expect("write file");
|
||||
|
||||
let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path)
|
||||
let auth = chatgpt_auth();
|
||||
let uploaded = upload_local_file(&base_url, &auth, &path)
|
||||
.await
|
||||
.expect("upload succeeds");
|
||||
|
||||
@@ -376,5 +433,25 @@ mod tests {
|
||||
assert_eq!(uploaded.file_name, "hello.txt");
|
||||
assert_eq!(uploaded.mime_type, Some("text/plain".to_string()));
|
||||
assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
*auth
|
||||
.observed_updates
|
||||
.lock()
|
||||
.expect("observed updates lock should not be poisoned"),
|
||||
vec![
|
||||
(
|
||||
format!("{}/files", base_url.trim_end_matches('/')),
|
||||
Some(CREATED_STATE.to_string()),
|
||||
),
|
||||
(
|
||||
format!("{}/files/file_123/uploaded", base_url.trim_end_matches('/')),
|
||||
Some(RETRY_STATE.to_string()),
|
||||
),
|
||||
(
|
||||
format!("{}/files/file_123/uploaded", base_url.trim_end_matches('/')),
|
||||
Some(FINALIZED_STATE.to_string()),
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,6 @@
|
||||
use super::ContextualUserFragment;
|
||||
use std::fmt::Display;
|
||||
|
||||
/// Maximum size of the extension's model-facing generated-image path hint.
|
||||
const MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES: usize = 1024;
|
||||
|
||||
/// Returns the extension's model-facing hint, or omits it if the path makes it too large.
|
||||
pub fn extension_image_generation_output_hint(
|
||||
image_output_dir: impl Display,
|
||||
image_output_path: impl Display,
|
||||
) -> Option<String> {
|
||||
let hint = image_generation_hint(image_output_dir, image_output_path);
|
||||
(hint.len() <= MAX_IMAGE_GENERATION_OUTPUT_HINT_BYTES).then_some(hint)
|
||||
}
|
||||
|
||||
fn image_generation_hint(
|
||||
image_output_dir: impl Display,
|
||||
image_output_path: impl Display,
|
||||
) -> String {
|
||||
format!(
|
||||
"Generated images are saved to {image_output_dir} as {image_output_path} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it."
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct ImageGenerationInstructions {
|
||||
image_output_dir: String,
|
||||
@@ -51,6 +30,9 @@ impl ContextualUserFragment for ImageGenerationInstructions {
|
||||
}
|
||||
|
||||
fn body(&self) -> String {
|
||||
image_generation_hint(&self.image_output_dir, &self.image_output_path)
|
||||
format!(
|
||||
"Generated images are saved to {} as {} by default.\nIf you need to use a generated image at another path, copy it and leave the original in place unless the user explicitly asks you to delete it.",
|
||||
self.image_output_dir, self.image_output_path
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ pub(crate) use fragments::AdditionalContextUserFragment;
|
||||
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
|
||||
pub(crate) use hook_additional_context::HookAdditionalContext;
|
||||
pub(crate) use image_generation_instructions::ImageGenerationInstructions;
|
||||
pub use image_generation_instructions::extension_image_generation_output_hint;
|
||||
pub use internal_model_context::InternalContextSource;
|
||||
pub use internal_model_context::InternalModelContextFragment;
|
||||
pub use internal_model_context::InvalidInternalContextSource;
|
||||
|
||||
@@ -97,7 +97,6 @@ pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod stream_events_utils;
|
||||
pub use stream_events_utils::image_generation_artifact_path;
|
||||
pub mod test_support;
|
||||
mod unified_exec;
|
||||
pub mod windows_sandbox;
|
||||
|
||||
@@ -38,8 +38,7 @@ use tracing::warn;
|
||||
|
||||
const GENERATED_IMAGE_ARTIFACTS_DIR: &str = "generated_images";
|
||||
|
||||
/// Returns the host-owned default artifact path for a generated image.
|
||||
pub fn image_generation_artifact_path(
|
||||
pub(crate) fn image_generation_artifact_path(
|
||||
codex_home: &AbsolutePathBuf,
|
||||
session_id: &str,
|
||||
call_id: &str,
|
||||
|
||||
@@ -23,7 +23,6 @@ codex-model-provider = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
http = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -4,7 +4,6 @@ The `image_gen.imagegen` tool enables image generation from descriptions and edi
|
||||
- The user wants to modify an attached or previously generated image with specific changes, including adding or removing elements, altering colors, improving quality/resolution, or transforming the style (e.g., cartoon, oil painting).
|
||||
|
||||
Guidelines:
|
||||
- In code mode, pass the result to `generatedImage(result)`.
|
||||
- Set `action` to `generate` when the user asks for a brand new image.
|
||||
- Set `action` to `edit` when the user asks to modify an existing image from the conversation history.
|
||||
- Directly generate the image without reconfirmation or clarification.
|
||||
|
||||
@@ -13,7 +13,6 @@ use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_model_provider::create_model_provider;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::backend::CodexImagesBackend;
|
||||
use crate::tool::ImageGenerationTool;
|
||||
@@ -27,7 +26,6 @@ struct ImageGenerationExtension {
|
||||
struct ImageGenerationExtensionConfig {
|
||||
enabled: bool,
|
||||
provider: ModelProviderInfo,
|
||||
codex_home: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl From<&Config> for ImageGenerationExtensionConfig {
|
||||
@@ -37,7 +35,6 @@ impl From<&Config> for ImageGenerationExtensionConfig {
|
||||
enabled: config.features.enabled(Feature::ImageGenExt)
|
||||
&& config.model_provider.is_openai(),
|
||||
provider: config.model_provider.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,14 +76,9 @@ impl ToolContributor for ImageGenerationExtension {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![Arc::new(ImageGenerationTool::new(
|
||||
CodexImagesBackend::new(create_model_provider(
|
||||
config.provider.clone(),
|
||||
Some(self.auth_manager.clone()),
|
||||
)),
|
||||
config.codex_home.clone(),
|
||||
thread_store.level_id().to_string(),
|
||||
))]
|
||||
vec![Arc::new(ImageGenerationTool::new(CodexImagesBackend::new(
|
||||
create_model_provider(config.provider.clone(), Some(self.auth_manager.clone())),
|
||||
)))]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use codex_api::ImageEditRequest;
|
||||
use codex_api::ImageGenerationRequest;
|
||||
use codex_api::ImageQuality;
|
||||
use codex_api::ImageUrl;
|
||||
use codex_core::context::extension_image_generation_output_hint;
|
||||
use codex_extension_api::ToolOutput;
|
||||
use codex_extension_api::ToolPayload;
|
||||
use codex_extension_api::ToolSpec;
|
||||
@@ -55,58 +54,9 @@ fn generate_uses_fixed_request_defaults() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_output_returns_image_input_and_output_hint() {
|
||||
let output_hint =
|
||||
extension_image_generation_output_hint("/tmp", "/tmp/call-1.png").expect("hint should fit");
|
||||
fn generated_output_returns_image_input() {
|
||||
let output = GeneratedImageOutput {
|
||||
result: RESULT.to_string(),
|
||||
output_hint: Some(output_hint.clone()),
|
||||
};
|
||||
|
||||
let ResponseInputItem::FunctionCallOutput {
|
||||
output: response_output,
|
||||
..
|
||||
} = output.to_response_item("call-1", &function_payload())
|
||||
else {
|
||||
panic!("imagegen should return function tool output");
|
||||
};
|
||||
let FunctionCallOutputBody::ContentItems(content_items) = response_output.body else {
|
||||
panic!("imagegen output should contain generated image bytes");
|
||||
};
|
||||
assert_eq!(
|
||||
content_items,
|
||||
vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: format!("data:image/png;base64,{RESULT}"),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText { text: output_hint },
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_output_returns_generated_image_helper_input_in_code_mode() {
|
||||
let output = GeneratedImageOutput {
|
||||
result: RESULT.to_string(),
|
||||
output_hint: Some("generated image save hint".to_string()),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
output.code_mode_result(&function_payload()),
|
||||
serde_json::json!({
|
||||
"image_url": format!("data:image/png;base64,{RESULT}"),
|
||||
"output_hint": "generated image save hint",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_output_omits_oversized_output_hint() {
|
||||
let long_path = "x".repeat(1024);
|
||||
let output = GeneratedImageOutput {
|
||||
result: RESULT.to_string(),
|
||||
output_hint: extension_image_generation_output_hint("/tmp", long_path),
|
||||
};
|
||||
|
||||
let ResponseInputItem::FunctionCallOutput {
|
||||
|
||||
@@ -3,8 +3,6 @@ use codex_api::ImageEditRequest;
|
||||
use codex_api::ImageGenerationRequest;
|
||||
use codex_api::ImageQuality;
|
||||
use codex_api::ImageUrl;
|
||||
use codex_core::context::extension_image_generation_output_hint;
|
||||
use codex_core::image_generation_artifact_path;
|
||||
use codex_extension_api::ExtensionTurnItem;
|
||||
use codex_extension_api::FunctionCallError;
|
||||
use codex_extension_api::ToolCall;
|
||||
@@ -27,7 +25,6 @@ use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolExposure;
|
||||
use codex_tools::default_namespace_description;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use schemars::JsonSchema;
|
||||
use schemars::r#gen::SchemaSettings;
|
||||
use serde::Deserialize;
|
||||
@@ -45,22 +42,12 @@ const IMAGEGEN_DESCRIPTION: &str = include_str!("../imagegen_description.md");
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ImageGenerationTool {
|
||||
backend: CodexImagesBackend,
|
||||
codex_home: AbsolutePathBuf,
|
||||
thread_id: String,
|
||||
}
|
||||
|
||||
impl ImageGenerationTool {
|
||||
/// Creates an image-generation tool backed by an image API executor.
|
||||
pub(crate) fn new(
|
||||
backend: CodexImagesBackend,
|
||||
codex_home: AbsolutePathBuf,
|
||||
thread_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
codex_home,
|
||||
thread_id,
|
||||
}
|
||||
pub(crate) fn new(backend: CodexImagesBackend) -> Self {
|
||||
Self { backend }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,17 +116,7 @@ impl ToolExecutor<ToolCall> for ImageGenerationTool {
|
||||
saved_path: None,
|
||||
}))
|
||||
.await;
|
||||
let output_path =
|
||||
image_generation_artifact_path(&self.codex_home, &self.thread_id, &call.call_id);
|
||||
let output_dir = output_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| self.codex_home.clone());
|
||||
let output_hint =
|
||||
extension_image_generation_output_hint(output_dir.display(), output_path.display());
|
||||
Ok(Box::new(GeneratedImageOutput {
|
||||
result,
|
||||
output_hint,
|
||||
}))
|
||||
Ok(Box::new(GeneratedImageOutput { result }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +297,6 @@ fn imagegen_tool_spec() -> ToolSpec {
|
||||
|
||||
struct GeneratedImageOutput {
|
||||
result: String,
|
||||
output_hint: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolOutput for GeneratedImageOutput {
|
||||
@@ -334,32 +310,12 @@ impl ToolOutput for GeneratedImageOutput {
|
||||
true
|
||||
}
|
||||
|
||||
/// Returns the object consumed by the code-mode `generatedImage()` helper.
|
||||
fn code_mode_result(&self, _payload: &ToolPayload) -> Value {
|
||||
let mut result = Map::from_iter([(
|
||||
"image_url".to_string(),
|
||||
Value::String(format!("data:image/png;base64,{}", self.result)),
|
||||
)]);
|
||||
if let Some(output_hint) = &self.output_hint {
|
||||
result.insert(
|
||||
"output_hint".to_string(),
|
||||
Value::String(output_hint.clone()),
|
||||
);
|
||||
}
|
||||
Value::Object(result)
|
||||
}
|
||||
|
||||
/// Returns generated bytes and persisted-artifact context for model follow-up.
|
||||
/// Returns generated bytes for model follow-up.
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
let mut content = vec![FunctionCallOutputContentItem::InputImage {
|
||||
let content = vec![FunctionCallOutputContentItem::InputImage {
|
||||
image_url: format!("data:image/png;base64,{}", self.result),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}];
|
||||
if let Some(output_hint) = &self.output_hint {
|
||||
content.push(FunctionCallOutputContentItem::InputText {
|
||||
text: output_hint.clone(),
|
||||
});
|
||||
}
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
|
||||
6
codex-rs/http-state/BUILD.bazel
Normal file
6
codex-rs/http-state/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "http-state",
|
||||
crate_name = "codex_http_state",
|
||||
)
|
||||
20
codex-rs/http-state/Cargo.toml
Normal file
20
codex-rs/http-state/Cargo.toml
Normal file
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
name = "codex-http-state"
|
||||
version.workspace = true
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
|
||||
[dependencies]
|
||||
codex-utils-path = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
191
codex-rs/http-state/src/lib.rs
Normal file
191
codex-rs/http-state/src/lib.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::PoisonError;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_utils_path::write_atomically;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
const STATE_DIR_NAME: &str = "state";
|
||||
|
||||
/// Native Codex surface that owns one local HTTP-state file.
|
||||
///
|
||||
/// Unknown app-server clients intentionally share the CLI state file. This
|
||||
/// preserves the default classification while first-party clients opt into a
|
||||
/// more specific surface by setting `clientInfo.name` during initialization.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum HttpStateSurface {
|
||||
CodexCli,
|
||||
CodexTui,
|
||||
CodexExec,
|
||||
CodexVscode,
|
||||
CodexDesktop,
|
||||
CodexDesktopSsh,
|
||||
CodexRemoteControl,
|
||||
}
|
||||
|
||||
impl HttpStateSurface {
|
||||
pub fn try_from_app_server_client_name(client_name: &str) -> Option<Self> {
|
||||
match client_name {
|
||||
"codex_cli" => Some(Self::CodexCli),
|
||||
"codex-tui" => Some(Self::CodexTui),
|
||||
"codex_exec" => Some(Self::CodexExec),
|
||||
"codex_vscode" => Some(Self::CodexVscode),
|
||||
"codex_desktop" => Some(Self::CodexDesktop),
|
||||
"codex_desktop_ssh" => Some(Self::CodexDesktopSsh),
|
||||
"codex_remote_control" => Some(Self::CodexRemoteControl),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_app_server_client_name(client_name: &str) -> Self {
|
||||
Self::try_from_app_server_client_name(client_name).unwrap_or(Self::CodexCli)
|
||||
}
|
||||
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::CodexCli => "codex_cli",
|
||||
Self::CodexTui => "codex_tui",
|
||||
Self::CodexExec => "codex_exec",
|
||||
Self::CodexVscode => "codex_vscode",
|
||||
Self::CodexDesktop => "codex_desktop",
|
||||
Self::CodexDesktopSsh => "codex_desktop_ssh",
|
||||
Self::CodexRemoteControl => "codex_remote_control",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for HttpStateSurface {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct HttpStateFile {
|
||||
state: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpStateStore {
|
||||
codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl HttpStateStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self { codex_home }
|
||||
}
|
||||
|
||||
fn state_path(&self, surface: HttpStateSurface) -> PathBuf {
|
||||
self.codex_home
|
||||
.join(STATE_DIR_NAME)
|
||||
.join(format!("{surface}.json"))
|
||||
}
|
||||
|
||||
pub fn get(&self, surface: HttpStateSurface) -> io::Result<Option<String>> {
|
||||
let path = self.state_path(surface);
|
||||
let contents = match fs::read(&path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let state_file: HttpStateFile =
|
||||
serde_json::from_slice(&contents).map_err(io::Error::other)?;
|
||||
Ok(Some(state_file.state))
|
||||
}
|
||||
|
||||
pub fn set(&self, surface: HttpStateSurface, state: String) -> io::Result<()> {
|
||||
self.write(surface, &HttpStateFile { state })
|
||||
}
|
||||
|
||||
pub fn clear(&self, surface: HttpStateSurface) -> io::Result<()> {
|
||||
match fs::remove_file(self.state_path(surface)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores `next_state` only if the local file still contains `expected_state`.
|
||||
///
|
||||
/// This is intentionally lock-free. Concurrent writers may both observe
|
||||
/// the same prior value, in which case atomic replacement keeps the file
|
||||
/// well-formed and the last writer wins.
|
||||
pub fn compare_and_set(
|
||||
&self,
|
||||
surface: HttpStateSurface,
|
||||
expected_state: &str,
|
||||
next_state: String,
|
||||
) -> io::Result<bool> {
|
||||
let Some(current) = self.get(surface)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if current != expected_state {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
self.write(surface, &HttpStateFile { state: next_state })?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn write(&self, surface: HttpStateSurface, state_file: &HttpStateFile) -> io::Result<()> {
|
||||
let path = self.state_path(surface);
|
||||
let contents = serde_json::to_string_pretty(state_file).map_err(io::Error::other)?;
|
||||
write_atomically(&path, &contents)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared surface selection for one native Codex client session.
|
||||
///
|
||||
/// App-server clients may update the selected surface after the model client is
|
||||
/// constructed. Requests snapshot the current value when they create their
|
||||
/// auth decorator, so clones of the model client stay in sync without moving
|
||||
/// in-flight rotations to another surface.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct HttpStateContext {
|
||||
store: HttpStateStore,
|
||||
surface: Arc<RwLock<HttpStateSurface>>,
|
||||
}
|
||||
|
||||
impl HttpStateContext {
|
||||
pub fn new(codex_home: PathBuf, surface: HttpStateSurface) -> Self {
|
||||
Self {
|
||||
store: HttpStateStore::new(codex_home),
|
||||
surface: Arc::new(RwLock::new(surface)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn surface(&self) -> HttpStateSurface {
|
||||
*self.surface.read().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub fn set_surface(&self, surface: HttpStateSurface) -> bool {
|
||||
let mut selected_surface = self.surface.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if *selected_surface == surface {
|
||||
return false;
|
||||
}
|
||||
*selected_surface = surface;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn get_for_surface(&self, surface: HttpStateSurface) -> io::Result<Option<String>> {
|
||||
self.store.get(surface)
|
||||
}
|
||||
|
||||
pub fn compare_and_set_for_surface(
|
||||
&self,
|
||||
surface: HttpStateSurface,
|
||||
expected_state: &str,
|
||||
next_state: String,
|
||||
) -> io::Result<bool> {
|
||||
self.store
|
||||
.compare_and_set(surface, expected_state, next_state)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
106
codex-rs/http-state/src/tests.rs
Normal file
106
codex-rs/http-state/src/tests.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const STATE_N: &str = "state-n";
|
||||
const STATE_N_PLUS_ONE: &str = "state-n-plus-one";
|
||||
|
||||
#[test]
|
||||
fn maps_app_server_client_names_to_bounded_surfaces() {
|
||||
assert_eq!(
|
||||
[
|
||||
"codex_cli",
|
||||
"codex-tui",
|
||||
"codex_exec",
|
||||
"codex_vscode",
|
||||
"codex_desktop",
|
||||
"codex_desktop_ssh",
|
||||
"codex_remote_control",
|
||||
"third_party_client",
|
||||
]
|
||||
.map(HttpStateSurface::from_app_server_client_name),
|
||||
[
|
||||
HttpStateSurface::CodexCli,
|
||||
HttpStateSurface::CodexTui,
|
||||
HttpStateSurface::CodexExec,
|
||||
HttpStateSurface::CodexVscode,
|
||||
HttpStateSurface::CodexDesktop,
|
||||
HttpStateSurface::CodexDesktopSsh,
|
||||
HttpStateSurface::CodexRemoteControl,
|
||||
HttpStateSurface::CodexCli,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
HttpStateSurface::try_from_app_server_client_name("third_party_client"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_state_in_one_file_per_surface() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = HttpStateStore::new(codex_home.path().to_path_buf());
|
||||
|
||||
store
|
||||
.set(HttpStateSurface::CodexCli, STATE_N.to_string())
|
||||
.expect("CLI state should store");
|
||||
store
|
||||
.set(HttpStateSurface::CodexDesktop, STATE_N_PLUS_ONE.to_string())
|
||||
.expect("desktop state should store");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexCli)
|
||||
.expect("CLI state should load")
|
||||
.expect("CLI state should exist"),
|
||||
STATE_N
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should load")
|
||||
.expect("desktop state should exist"),
|
||||
STATE_N_PLUS_ONE
|
||||
);
|
||||
assert_eq!(
|
||||
store.state_path(HttpStateSurface::CodexCli),
|
||||
codex_home.path().join("state/codex_cli.json")
|
||||
);
|
||||
store
|
||||
.clear(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should clear");
|
||||
assert_eq!(
|
||||
store
|
||||
.get(HttpStateSurface::CodexDesktop)
|
||||
.expect("desktop state should load"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_set_rejects_a_stale_prior_value() {
|
||||
let codex_home = TempDir::new().expect("tempdir");
|
||||
let store = HttpStateStore::new(codex_home.path().to_path_buf());
|
||||
store
|
||||
.set(HttpStateSurface::CodexCli, STATE_N.to_string())
|
||||
.expect("state should store");
|
||||
|
||||
assert!(
|
||||
!store
|
||||
.compare_and_set(
|
||||
HttpStateSurface::CodexCli,
|
||||
"stale-state",
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.compare_and_set(
|
||||
HttpStateSurface::CodexCli,
|
||||
STATE_N,
|
||||
STATE_N_PLUS_ONE.to_string(),
|
||||
)
|
||||
.expect("compare should succeed")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user