mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
merge main into resume fork optimization
This commit is contained in:
@@ -137,17 +137,17 @@ Example with notification opt-out:
|
||||
|
||||
## API Overview
|
||||
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
|
||||
- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive.
|
||||
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`.
|
||||
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration.
|
||||
- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration.
|
||||
- `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known.
|
||||
- `thread/loaded/list` — list the thread ids currently loaded in memory.
|
||||
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
|
||||
- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`.
|
||||
- `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error.
|
||||
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
|
||||
- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
|
||||
- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `none`, `explicitRequestOnly`, or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings.
|
||||
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
|
||||
- `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success.
|
||||
- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`.
|
||||
@@ -168,7 +168,7 @@ Example with notification opt-out:
|
||||
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
|
||||
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
|
||||
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions.
|
||||
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `none`, `explicitRequestOnly`, or `proactive`; `none` keeps the tools available without injecting mode instructions, and omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations, and eligible multi-agent v2 turns use it directly.
|
||||
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
|
||||
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`.
|
||||
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
|
||||
@@ -202,7 +202,7 @@ Example with notification opt-out:
|
||||
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names.
|
||||
- `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider.
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
|
||||
- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.<id>]` entries to be included in the current catalog view.
|
||||
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored.
|
||||
- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
@@ -714,9 +714,11 @@ If the thread does not already have an active turn, the server starts a standalo
|
||||
Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions:
|
||||
|
||||
- `{"type":"text","text":"Explain this diff"}`
|
||||
- `{"type":"image","url":"https://…png"}`
|
||||
- `{"type":"image","url":"data:image/png;base64,…"}`
|
||||
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
|
||||
|
||||
The `image` variant accepts inline data URLs. Remote HTTP(S) image URLs are rejected; use a data URL or `localImage` instead.
|
||||
|
||||
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only.
|
||||
|
||||
`approvalsReviewer` accepts:
|
||||
@@ -828,7 +830,7 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in
|
||||
|
||||
### Example: Inject raw history items
|
||||
|
||||
Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests.
|
||||
Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected.
|
||||
|
||||
```json
|
||||
{ "method": "thread/inject_items", "id": 36, "params": {
|
||||
@@ -1353,6 +1355,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn
|
||||
- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`.
|
||||
- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items.
|
||||
- `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`.
|
||||
- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons, showBufferingUi, fasterModel }` when a response enters safety buffering. `fasterModel` is nullable. This notification is transient and is not persisted in rollout history.
|
||||
- `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks).
|
||||
- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`.
|
||||
- `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation.
|
||||
@@ -1584,7 +1587,7 @@ The server also emits item lifecycle notifications around the request:
|
||||
3. Client response.
|
||||
4. `item/completed` with `item.type = "dynamicToolCall"`, final `status`, and the returned `contentItems`/`success`.
|
||||
|
||||
The client must respond with content items. Use `inputText` for text and `inputImage` for image URLs/data URLs:
|
||||
The client must respond with content items. Use `inputText` for text and `inputImage` for inline data URLs. Remote HTTP(S) image URLs make the dynamic tool response invalid.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1919,6 +1922,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only.
|
||||
- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key.
|
||||
- `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets.
|
||||
- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available.
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot.
|
||||
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
|
||||
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
|
||||
@@ -1939,6 +1943,7 @@ Response examples:
|
||||
{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models)
|
||||
{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } }
|
||||
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } }
|
||||
{ "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } }
|
||||
```
|
||||
@@ -1946,6 +1951,7 @@ Response examples:
|
||||
Field notes:
|
||||
|
||||
- `refreshToken` (bool): set `true` to force a token refresh.
|
||||
- `email` is `null` when the ChatGPT account does not have an email address.
|
||||
- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials.
|
||||
- Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials.
|
||||
|
||||
@@ -2045,7 +2051,18 @@ Field notes:
|
||||
- `noCredit` means the account has no earned reset credits available.
|
||||
- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response.
|
||||
|
||||
### 9) Notify a workspace owner about a limit
|
||||
### 9) Workspace messages (ChatGPT)
|
||||
|
||||
```json
|
||||
{ "method": "account/workspaceMessages/read", "id": 9 }
|
||||
{ "id": 9, "result": { "featureEnabled": true, "messages": [
|
||||
{ "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null }
|
||||
] } }
|
||||
```
|
||||
|
||||
When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty.
|
||||
|
||||
### 10) Notify a workspace owner about a limit
|
||||
|
||||
```json
|
||||
{ "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } }
|
||||
|
||||
@@ -40,6 +40,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse;
|
||||
use codex_app_server_protocol::McpServerStartupState;
|
||||
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
|
||||
use codex_app_server_protocol::ModelReroutedNotification;
|
||||
use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification;
|
||||
use codex_app_server_protocol::ModelVerificationNotification;
|
||||
use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext;
|
||||
use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment;
|
||||
@@ -351,6 +352,22 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.send_server_notification(ServerNotification::TurnModerationMetadata(notification))
|
||||
.await;
|
||||
}
|
||||
EventMsg::SafetyBuffering(event) => {
|
||||
let notification = ModelSafetyBufferingUpdatedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
turn_id: event_turn_id.clone(),
|
||||
model: event.model,
|
||||
use_cases: event.use_cases,
|
||||
reasons: event.reasons,
|
||||
show_buffering_ui: event.show_buffering_ui,
|
||||
faster_model: event.faster_model,
|
||||
};
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::ModelSafetyBufferingUpdated(
|
||||
notification,
|
||||
))
|
||||
.await;
|
||||
}
|
||||
EventMsg::RealtimeConversationStarted(event) => {
|
||||
let notification = ThreadRealtimeStartedNotification {
|
||||
thread_id: conversation_id.to_string(),
|
||||
|
||||
@@ -8,6 +8,8 @@ use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use tracing::error;
|
||||
|
||||
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
||||
use crate::image_url::is_remote_image_url;
|
||||
use crate::outgoing_message::ClientRequestResult;
|
||||
use crate::server_request_error::is_turn_transition_server_request_error;
|
||||
|
||||
@@ -54,6 +56,21 @@ pub(crate) async fn on_call_response(
|
||||
|
||||
fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option<String>) {
|
||||
match serde_json::from_value::<DynamicToolCallResponse>(value) {
|
||||
Ok(response)
|
||||
if response.content_items.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
DynamicToolCallOutputContentItem::InputImage { image_url }
|
||||
if is_remote_image_url(image_url)
|
||||
)
|
||||
}) =>
|
||||
{
|
||||
error!(
|
||||
message = REMOTE_IMAGE_URL_ERROR,
|
||||
"dynamic tool response was invalid"
|
||||
);
|
||||
fallback_response(REMOTE_IMAGE_URL_ERROR)
|
||||
}
|
||||
Ok(response) => (response, None),
|
||||
Err(err) => {
|
||||
error!("failed to deserialize DynamicToolCallResponse: {err}");
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
pub(crate) const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
pub(crate) fn is_remote_image_url(image_url: &str) -> bool {
|
||||
image_url.split_once(':').is_some_and(|(scheme, _)| {
|
||||
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
|
||||
})
|
||||
}
|
||||
@@ -68,13 +68,11 @@ use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
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;
|
||||
@@ -98,6 +96,7 @@ mod extensions;
|
||||
mod filters;
|
||||
mod fs_watch;
|
||||
mod fuzzy_file_search;
|
||||
mod image_url;
|
||||
pub mod in_process;
|
||||
mod mcp_refresh;
|
||||
mod message_processor;
|
||||
@@ -666,7 +665,7 @@ pub async fn run_main_with_transport_options(
|
||||
let log_db = state_db.clone().map(log_db::start);
|
||||
let log_db_layer = log_db
|
||||
.clone()
|
||||
.map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE)));
|
||||
.map(|layer| layer.with_filter(log_db::default_filter()));
|
||||
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());
|
||||
let _ = tracing_subscriber::registry()
|
||||
|
||||
@@ -1427,6 +1427,9 @@ impl MessageProcessor {
|
||||
ClientRequest::GetAccountTokenUsage { .. } => {
|
||||
self.account_processor.get_account_token_usage().await
|
||||
}
|
||||
ClientRequest::GetWorkspaceMessages { .. } => {
|
||||
self.account_processor.get_workspace_messages().await
|
||||
}
|
||||
ClientRequest::SendAddCreditsNudgeEmail { params, .. } => {
|
||||
self.account_processor
|
||||
.send_add_credits_nudge_email(params)
|
||||
|
||||
@@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::GetConversationSummaryParams;
|
||||
use codex_app_server_protocol::GetConversationSummaryResponse;
|
||||
use codex_app_server_protocol::GetWorkspaceMessagesResponse;
|
||||
use codex_app_server_protocol::GitDiffToRemoteParams;
|
||||
use codex_app_server_protocol::GitDiffToRemoteResponse;
|
||||
use codex_app_server_protocol::GitInfo as ApiGitInfo;
|
||||
@@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
|
||||
use codex_app_server_protocol::WindowsSandboxSetupMode;
|
||||
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
|
||||
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
|
||||
use codex_app_server_protocol::WorkspaceMessage;
|
||||
use codex_app_server_protocol::WorkspaceMessageType;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage;
|
||||
use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType;
|
||||
use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse;
|
||||
use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode;
|
||||
use codex_backend_client::RequestError as BackendRequestError;
|
||||
use codex_backend_client::TokenUsageProfile;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_chatgpt::workspace_settings;
|
||||
@@ -330,7 +337,6 @@ use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
|
||||
use codex_core_plugins::PluginsManager;
|
||||
use codex_core_plugins::loader::load_plugin_apps;
|
||||
use codex_core_plugins::loader::load_plugin_mcp_servers;
|
||||
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
|
||||
use codex_core_plugins::manifest::PluginManifestInterface;
|
||||
use codex_core_plugins::marketplace::MarketplaceError;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginSource;
|
||||
@@ -390,9 +396,6 @@ use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY;
|
||||
use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
use super::*;
|
||||
use chrono::DateTime;
|
||||
|
||||
mod rate_limit_resets;
|
||||
|
||||
// Duration before a browser ChatGPT login attempt is abandoned.
|
||||
const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
|
||||
const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration =
|
||||
Duration::from_millis(/*millis*/ 1000);
|
||||
// The override is intentionally available only in debug builds, matching the login path below.
|
||||
#[cfg(debug_assertions)]
|
||||
const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER";
|
||||
@@ -142,6 +145,14 @@ impl AccountRequestProcessor {
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_workspace_messages(
|
||||
&self,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.get_workspace_messages_response()
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
}
|
||||
|
||||
pub(crate) async fn send_add_credits_nudge_email(
|
||||
&self,
|
||||
params: SendAddCreditsNudgeEmailParams,
|
||||
@@ -348,6 +359,7 @@ impl AccountRequestProcessor {
|
||||
config.forced_chatgpt_workspace_id.clone(),
|
||||
config.cli_auth_credentials_store_mode,
|
||||
config.auth_keyring_backend_kind(),
|
||||
config.auth_route_config(),
|
||||
)
|
||||
};
|
||||
#[cfg(debug_assertions)]
|
||||
@@ -943,6 +955,48 @@ impl AccountRequestProcessor {
|
||||
Ok(Self::account_token_usage_response(profile))
|
||||
}
|
||||
|
||||
async fn get_workspace_messages_response(
|
||||
&self,
|
||||
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(invalid_request(
|
||||
"codex account authentication required to read workspace messages",
|
||||
));
|
||||
};
|
||||
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(invalid_request(
|
||||
"chatgpt authentication required to read workspace messages",
|
||||
));
|
||||
}
|
||||
|
||||
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
|
||||
.map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?;
|
||||
let messages = tokio::time::timeout(
|
||||
ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT,
|
||||
client.list_workspace_messages(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| internal_error("workspace messages fetch timed out"))?;
|
||||
|
||||
match messages {
|
||||
Ok(messages) => {
|
||||
Self::workspace_messages_response(messages, /*feature_enabled*/ true)
|
||||
}
|
||||
Err(err) if workspace_messages_feature_disabled(&err) => {
|
||||
Self::workspace_messages_response(
|
||||
BackendWorkspaceMessagesResponse {
|
||||
messages: Vec::new(),
|
||||
},
|
||||
/*feature_enabled*/ false,
|
||||
)
|
||||
}
|
||||
Err(err) => Err(internal_error(format!(
|
||||
"failed to fetch workspace messages: {err}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse {
|
||||
let stats = profile.stats;
|
||||
GetAccountTokenUsageResponse {
|
||||
@@ -965,6 +1019,20 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_messages_response(
|
||||
messages: BackendWorkspaceMessagesResponse,
|
||||
feature_enabled: bool,
|
||||
) -> Result<GetWorkspaceMessagesResponse, JSONRPCErrorError> {
|
||||
Ok(GetWorkspaceMessagesResponse {
|
||||
feature_enabled,
|
||||
messages: messages
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(workspace_message_from_backend)
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_add_credits_nudge_email_response(
|
||||
&self,
|
||||
params: SendAddCreditsNudgeEmailParams,
|
||||
@@ -1015,6 +1083,48 @@ impl AccountRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_message_from_backend(
|
||||
message: BackendWorkspaceMessage,
|
||||
) -> Result<WorkspaceMessage, JSONRPCErrorError> {
|
||||
Ok(WorkspaceMessage {
|
||||
message_id: message.message_id,
|
||||
message_type: workspace_message_type_from_backend(message.message_type),
|
||||
message_body: message.message_body,
|
||||
created_at: workspace_message_timestamp_from_backend(message.created_at)?,
|
||||
archived_at: workspace_message_timestamp_from_backend(message.archived_at)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace_message_timestamp_from_backend(
|
||||
timestamp: Option<String>,
|
||||
) -> Result<Option<i64>, JSONRPCErrorError> {
|
||||
timestamp
|
||||
.map(|timestamp| {
|
||||
DateTime::parse_from_rfc3339(×tamp)
|
||||
.map(|timestamp| timestamp.timestamp())
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to parse workspace message timestamp `{timestamp}`: {err}"
|
||||
))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn workspace_message_type_from_backend(
|
||||
message_type: BackendWorkspaceMessageType,
|
||||
) -> WorkspaceMessageType {
|
||||
match message_type {
|
||||
BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline,
|
||||
BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement,
|
||||
BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool {
|
||||
err.status().is_some_and(|status| status.as_u16() == 404)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1055,4 +1165,55 @@ mod tests {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_messages_response_maps_backend_messages() {
|
||||
let response = AccountRequestProcessor::workspace_messages_response(
|
||||
BackendWorkspaceMessagesResponse {
|
||||
messages: vec![BackendWorkspaceMessage {
|
||||
message_id: "headline-id".to_string(),
|
||||
message_type: BackendWorkspaceMessageType::Headline,
|
||||
message_body: "Headline body".to_string(),
|
||||
created_at: Some("2026-06-14T00:00:00Z".to_string()),
|
||||
archived_at: Some("2026-06-15T00:00:00Z".to_string()),
|
||||
}],
|
||||
},
|
||||
/*feature_enabled*/ true,
|
||||
)
|
||||
.expect("workspace message timestamps should parse");
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
GetWorkspaceMessagesResponse {
|
||||
feature_enabled: true,
|
||||
messages: vec![WorkspaceMessage {
|
||||
message_id: "headline-id".to_string(),
|
||||
message_type: WorkspaceMessageType::Headline,
|
||||
message_body: "Headline body".to_string(),
|
||||
created_at: Some(1_781_395_200),
|
||||
archived_at: Some(1_781_481_600),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_messages_feature_disabled_only_for_not_found() {
|
||||
let cases = [
|
||||
(reqwest::StatusCode::NOT_FOUND, true),
|
||||
(reqwest::StatusCode::UNAUTHORIZED, false),
|
||||
(reqwest::StatusCode::FORBIDDEN, false),
|
||||
];
|
||||
|
||||
for (status, expected) in cases {
|
||||
let err = BackendRequestError::UnexpectedStatus {
|
||||
method: "GET".to_string(),
|
||||
url: "https://example.test/api/codex/workspace-messages".to_string(),
|
||||
status,
|
||||
content_type: "application/json".to_string(),
|
||||
body: "{}".to_string(),
|
||||
};
|
||||
assert_eq!(workspace_messages_feature_disabled(&err), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_core::config::permission_profile_catalog;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -434,35 +434,15 @@ impl CatalogRequestProcessor {
|
||||
.await
|
||||
.map_err(|err| internal_error(format!("failed to reload config: {err}")))?,
|
||||
};
|
||||
let effective_config: ConfigToml = config_layer_stack
|
||||
.effective_config()
|
||||
.try_into()
|
||||
.map_err(|err| internal_error(format!("failed to read effective config: {err}")))?;
|
||||
let mut profiles = vec![
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
|
||||
description: None,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
|
||||
description: None,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
|
||||
description: None,
|
||||
},
|
||||
];
|
||||
let mut configured_profiles = effective_config
|
||||
.permissions
|
||||
let profiles = permission_profile_catalog(&config_layer_stack)
|
||||
.map_err(|err| internal_error(format!("failed to resolve permission profiles: {err}")))?
|
||||
.into_iter()
|
||||
.flat_map(|permissions| permissions.entries)
|
||||
.map(|(id, profile)| PermissionProfileSummary {
|
||||
id,
|
||||
.map(|profile| PermissionProfileSummary {
|
||||
id: profile.id,
|
||||
description: profile.description,
|
||||
allowed: profile.allowed,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_profiles.sort_by(|left, right| left.id.cmp(&right.id));
|
||||
profiles.extend(configured_profiles);
|
||||
let total = profiles.len();
|
||||
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
|
||||
let effective_limit = effective_limit.min(total);
|
||||
|
||||
@@ -295,15 +295,14 @@ impl ConfigRequestProcessor {
|
||||
&self,
|
||||
pending_changes: std::collections::BTreeMap<String, bool>,
|
||||
) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
for (plugin_id, enabled) in pending_changes {
|
||||
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata(
|
||||
self.config_manager.codex_home(),
|
||||
&plugin_id,
|
||||
)
|
||||
.await;
|
||||
let metadata = plugins_manager
|
||||
.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
.await;
|
||||
if enabled {
|
||||
self.analytics_events_client.track_plugin_enabled(metadata);
|
||||
} else {
|
||||
|
||||
@@ -200,6 +200,7 @@ impl ExternalAgentSessionImporter {
|
||||
};
|
||||
let now = Utc::now();
|
||||
let create_params = CreateThreadParams {
|
||||
session_id: thread_id.into(),
|
||||
thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
|
||||
@@ -23,7 +23,6 @@ use codex_mcp::McpOAuthLoginSupport;
|
||||
use codex_mcp::oauth_login_support;
|
||||
use codex_mcp::should_retry_without_scopes;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_rmcp_client::perform_oauth_login_silent;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -78,7 +77,9 @@ fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginI
|
||||
composer_icon: interface.composer_icon,
|
||||
composer_icon_url: None,
|
||||
logo: interface.logo,
|
||||
logo_dark: interface.logo_dark,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
screenshots: interface.screenshots,
|
||||
screenshot_urls: Vec::new(),
|
||||
}
|
||||
@@ -746,11 +747,10 @@ impl PluginRequestProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
let featured_plugin_ids = if !plugins_input.remote_plugin_enabled
|
||||
&& data
|
||||
.iter()
|
||||
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
|
||||
{
|
||||
let featured_plugin_ids = if data.iter().any(|marketplace| {
|
||||
marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME
|
||||
|| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME
|
||||
}) {
|
||||
match plugins_manager
|
||||
.featured_plugin_ids_for_config(&plugins_input, auth.as_ref())
|
||||
.await
|
||||
@@ -1620,9 +1620,14 @@ impl PluginRequestProcessor {
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
);
|
||||
|
||||
let mut plugin_metadata =
|
||||
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await;
|
||||
plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone());
|
||||
let plugin_metadata = self
|
||||
.thread_manager
|
||||
.plugins_manager()
|
||||
.telemetry_metadata_for_installed_plugin_with_remote_id(
|
||||
&result.plugin_id,
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await;
|
||||
self.analytics_events_client
|
||||
.track_plugin_installed(plugin_metadata);
|
||||
|
||||
@@ -1713,8 +1718,10 @@ impl PluginRequestProcessor {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id);
|
||||
plugin.remote_plugin_id = Some(remote_plugin_id.to_string());
|
||||
let plugin = self
|
||||
.thread_manager
|
||||
.plugins_manager()
|
||||
.telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id);
|
||||
self.analytics_events_client
|
||||
.track_plugin_install_failed(plugin, error_type.to_string());
|
||||
}
|
||||
|
||||
@@ -2782,17 +2782,18 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
}
|
||||
}
|
||||
let response_history_items = (include_turns
|
||||
|| (initial_turns_page.is_some() && initial_turns_recent_items.is_none()))
|
||||
.then(|| {
|
||||
let mut response_history_items_storage = if include_turns
|
||||
|| (initial_turns_page.is_some() && initial_turns_recent_items.is_none())
|
||||
{
|
||||
if matches!(history_extent, ResumeHistoryExtent::CompactedCheckpoint) {
|
||||
None
|
||||
} else {
|
||||
Some(thread_history.get_rollout_items())
|
||||
Some(thread_history.get_rollout_items().to_vec())
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
let response_history_items = if response_history_items.is_none()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if response_history_items_storage.is_none()
|
||||
&& initial_turns_page.is_some()
|
||||
&& initial_turns_recent_items.is_none()
|
||||
{
|
||||
@@ -2800,15 +2801,14 @@ impl ThreadRequestProcessor {
|
||||
.load_full_resume_history_items(&thread_id, path.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(items) => Some(items),
|
||||
Ok(items) => response_history_items_storage = Some(items),
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response_history_items
|
||||
};
|
||||
}
|
||||
let response_history_items = response_history_items_storage.as_deref();
|
||||
|
||||
match self
|
||||
.thread_manager
|
||||
@@ -2852,7 +2852,7 @@ impl ThreadRequestProcessor {
|
||||
codex_thread.as_ref(),
|
||||
ResumeResponseSource {
|
||||
history: &response_history,
|
||||
items: response_history_items.as_deref(),
|
||||
items: response_history_items,
|
||||
},
|
||||
rollout_path.as_path(),
|
||||
resume_source_thread,
|
||||
@@ -2897,7 +2897,7 @@ impl ThreadRequestProcessor {
|
||||
config_snapshot.active_permission_profile,
|
||||
);
|
||||
let inline_history_items = if include_turns {
|
||||
let Some(history_items) = response_history_items.as_deref() else {
|
||||
let Some(history_items) = response_history_items else {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
@@ -2927,7 +2927,7 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let Some(history_items) = response_history_items.as_deref() else {
|
||||
let Some(history_items) = response_history_items else {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
@@ -3113,7 +3113,7 @@ impl ThreadRequestProcessor {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread {
|
||||
if let Some((existing_thread_id, existing_thread, mut source_thread)) = running_thread {
|
||||
let existing_thread_rollout_path = existing_thread.rollout_path();
|
||||
let active_path = existing_thread_rollout_path
|
||||
.as_ref()
|
||||
@@ -3175,8 +3175,8 @@ impl ThreadRequestProcessor {
|
||||
should_redact_thread_resume_payloads(app_server_client_name.as_deref());
|
||||
let history_items = source_thread
|
||||
.history
|
||||
.as_ref()
|
||||
.map(|history| history.items.clone())
|
||||
.take()
|
||||
.map(|history| history.items)
|
||||
.ok_or_else(|| {
|
||||
internal_error(format!(
|
||||
"thread {existing_thread_id} did not include persisted history"
|
||||
@@ -3200,10 +3200,8 @@ impl ThreadRequestProcessor {
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut summary_source_thread = source_thread;
|
||||
summary_source_thread.history = None;
|
||||
let mut thread_summary = self.stored_thread_to_api_thread(
|
||||
summary_source_thread,
|
||||
source_thread,
|
||||
config_snapshot.model_provider_id.as_str(),
|
||||
/*include_turns*/ false,
|
||||
);
|
||||
@@ -3451,7 +3449,7 @@ impl ThreadRequestProcessor {
|
||||
})?;
|
||||
Ok(InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: thread_id,
|
||||
history,
|
||||
history: Arc::new(history),
|
||||
rollout_path: stored_thread.rollout_path.clone(),
|
||||
}))
|
||||
}
|
||||
@@ -3668,7 +3666,7 @@ impl ThreadRequestProcessor {
|
||||
let history_items = source_thread
|
||||
.history
|
||||
.take()
|
||||
.map(|history| history.items)
|
||||
.map(|history| Arc::new(history.items))
|
||||
.ok_or_else(|| {
|
||||
internal_error(format!(
|
||||
"thread {source_thread_id} did not include persisted history"
|
||||
|
||||
@@ -403,7 +403,7 @@ mod thread_processor_behavior_tests {
|
||||
role: role.to_string(),
|
||||
content,
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -412,6 +412,8 @@ mod thread_processor_behavior_tests {
|
||||
message: message.to_string(),
|
||||
replacement_history: Some(vec![compacted_resume_test_message("assistant", message)]),
|
||||
window_number: Some(window_id),
|
||||
first_window_id: None,
|
||||
previous_window_id: None,
|
||||
window_id: None,
|
||||
})
|
||||
}
|
||||
@@ -720,9 +722,7 @@ mod thread_processor_behavior_tests {
|
||||
},
|
||||
)),
|
||||
compacted_resume_test_user_event("first user event"),
|
||||
RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::CompactionTrigger {
|
||||
metadata: None,
|
||||
}),
|
||||
RolloutItem::ResponseItem(codex_protocol::models::ResponseItem::CompactionTrigger {}),
|
||||
compacted_resume_test_agent_event("first agent event"),
|
||||
])?;
|
||||
std::fs::write(&path, contents)?;
|
||||
@@ -1411,6 +1411,7 @@ mod thread_processor_behavior_tests {
|
||||
let timestamp = "2025-09-05T16:53:11.850Z".to_string();
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
session_id: conversation_id.into(),
|
||||
id: conversation_id,
|
||||
timestamp: timestamp.clone(),
|
||||
model_provider: None,
|
||||
@@ -1467,6 +1468,7 @@ mod thread_processor_behavior_tests {
|
||||
let timestamp = "2025-09-05T16:53:11.850Z".to_string();
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
session_id: parent_thread_id.into(),
|
||||
id: conversation_id,
|
||||
timestamp: timestamp.clone(),
|
||||
source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
@@ -1517,6 +1519,7 @@ mod thread_processor_behavior_tests {
|
||||
let timestamp = "2025-09-05T16:53:11.850Z".to_string();
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
session_id: conversation_id.into(),
|
||||
id: conversation_id,
|
||||
forked_from_id: Some(forked_from_id),
|
||||
timestamp: timestamp.clone(),
|
||||
|
||||
@@ -13,6 +13,7 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
|
||||
|
||||
let head = vec![
|
||||
json!({
|
||||
"session_id": conversation_id.to_string(),
|
||||
"id": conversation_id.to_string(),
|
||||
"timestamp": timestamp,
|
||||
"cwd": "/",
|
||||
|
||||
@@ -1,14 +1,71 @@
|
||||
use super::*;
|
||||
use codex_protocol::config_types::MultiAgentMode;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry;
|
||||
use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind;
|
||||
use codex_protocol::protocol::MultiAgentVersion;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
|
||||
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
|
||||
use crate::image_url::is_remote_image_url;
|
||||
|
||||
const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str =
|
||||
"direct app-server input is not allowed for multi-agent v2 sub-agents";
|
||||
|
||||
fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> {
|
||||
if input.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
V2UserInput::Image { url, .. } if is_remote_image_url(url)
|
||||
)
|
||||
}) {
|
||||
return Err(invalid_request(REMOTE_IMAGE_URL_ERROR));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONRPCErrorError> {
|
||||
if items.iter().any(|item| match item {
|
||||
ResponseItem::Message { content, .. } => content.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ContentItem::InputImage { image_url, .. } if is_remote_image_url(image_url)
|
||||
)
|
||||
}),
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
output.content_items().is_some_and(|content| {
|
||||
content.iter().any(|item| {
|
||||
matches!(
|
||||
item,
|
||||
FunctionCallOutputContentItem::InputImage { image_url, .. }
|
||||
if is_remote_image_url(image_url)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger { .. }
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::AdditionalTools { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}) {
|
||||
return Err(invalid_request(REMOTE_IMAGE_URL_ERROR));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TurnRequestProcessor {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
@@ -105,6 +162,7 @@ impl TurnRequestProcessor {
|
||||
app_server_client_version: Option<String>,
|
||||
supports_openai_form_elicitation: bool,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
validate_user_input_image_urls(¶ms.input)?;
|
||||
self.turn_start_inner(
|
||||
request_id,
|
||||
params,
|
||||
@@ -140,6 +198,7 @@ impl TurnRequestProcessor {
|
||||
request_id: &ConnectionRequestId,
|
||||
params: TurnSteerParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
validate_user_input_image_urls(¶ms.input)?;
|
||||
self.turn_steer_inner(request_id, params)
|
||||
.await
|
||||
.map(|response| Some(response.into()))
|
||||
@@ -764,6 +823,7 @@ impl TurnRequestProcessor {
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(invalid_request)?;
|
||||
validate_response_item_image_urls(&items)?;
|
||||
|
||||
thread
|
||||
.inject_response_items(items)
|
||||
@@ -1177,7 +1237,7 @@ impl TurnRequestProcessor {
|
||||
config.clone(),
|
||||
InitialHistory::Resumed(ResumedHistory {
|
||||
conversation_id: parent_thread_id,
|
||||
history: parent_history.items,
|
||||
history: Arc::new(parent_history.items),
|
||||
rollout_path: parent_thread.rollout_path(),
|
||||
}),
|
||||
/*thread_source*/ None,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use codex_protocol::SessionId;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
@@ -127,11 +128,12 @@ pub fn create_fake_rollout_with_source(
|
||||
model_provider,
|
||||
git_info,
|
||||
source,
|
||||
/*session_id*/ None,
|
||||
/*parent_thread_id*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a minimal rollout file with an explicit session source and control parent.
|
||||
/// Create a minimal rollout file with an explicit root session and control parent.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_fake_parented_rollout_with_source(
|
||||
codex_home: &Path,
|
||||
@@ -141,6 +143,7 @@ pub fn create_fake_parented_rollout_with_source(
|
||||
model_provider: Option<&str>,
|
||||
git_info: Option<GitInfo>,
|
||||
source: SessionSource,
|
||||
session_id: SessionId,
|
||||
parent_thread_id: ThreadId,
|
||||
) -> Result<String> {
|
||||
create_fake_rollout_with_source_and_parent_thread_id(
|
||||
@@ -151,6 +154,7 @@ pub fn create_fake_parented_rollout_with_source(
|
||||
model_provider,
|
||||
git_info,
|
||||
source,
|
||||
Some(session_id),
|
||||
Some(parent_thread_id),
|
||||
)
|
||||
}
|
||||
@@ -164,11 +168,13 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
|
||||
model_provider: Option<&str>,
|
||||
git_info: Option<GitInfo>,
|
||||
source: SessionSource,
|
||||
session_id: Option<SessionId>,
|
||||
parent_thread_id: Option<ThreadId>,
|
||||
) -> Result<String> {
|
||||
let uuid = Uuid::new_v4();
|
||||
let uuid_str = uuid.to_string();
|
||||
let conversation_id = ThreadId::from_string(&uuid_str)?;
|
||||
let session_id = session_id.unwrap_or_else(|| conversation_id.into());
|
||||
|
||||
let file_path = rollout_path(codex_home, filename_ts, &uuid_str);
|
||||
let dir = file_path
|
||||
@@ -178,6 +184,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id(
|
||||
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
session_id,
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id,
|
||||
@@ -264,6 +271,7 @@ pub fn create_fake_rollout_with_text_elements(
|
||||
|
||||
// Build JSONL lines
|
||||
let meta = SessionMeta {
|
||||
session_id: conversation_id.into(),
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
|
||||
@@ -5,7 +5,10 @@ use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use chrono::Duration;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
@@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
@@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> {
|
||||
async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path())?;
|
||||
|
||||
@@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
|
||||
.and(path("/v1/user-auth-credential/whoami"))
|
||||
.and(header("Authorization", "Bearer at-test-token"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"email": "user@example.com",
|
||||
"email": null,
|
||||
"chatgpt_user_id": "user-123",
|
||||
"chatgpt_account_id": "account-123",
|
||||
"chatgpt_plan_type": "pro",
|
||||
@@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()>
|
||||
}
|
||||
);
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
response
|
||||
.result
|
||||
.get("account")
|
||||
.and_then(|account| account.get("email")),
|
||||
Some(&serde_json::Value::Null),
|
||||
);
|
||||
assert_eq!(
|
||||
to_response::<GetAccountResponse>(response)?,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -121,6 +121,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() ->
|
||||
let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?;
|
||||
store
|
||||
.create_thread(CreateThreadParams {
|
||||
session_id: thread_id.into(),
|
||||
thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
|
||||
@@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> {
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
email: Some("embedded@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> {
|
||||
account,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "embedded@example.com".to_string(),
|
||||
email: Some("embedded@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> {
|
||||
|
||||
let expected = GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
@@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_with_chatgpt_without_email() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp =
|
||||
TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: GetAccountResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
received,
|
||||
GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result
|
||||
|
||||
let expected = GetAccountResponse {
|
||||
account: Some(Account::Chatgpt {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Unknown,
|
||||
}),
|
||||
requires_openai_auth: true,
|
||||
|
||||
@@ -63,7 +63,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
thread_source: Some(ThreadSource::Feature("automation".to_string())),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -75,7 +78,6 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
|
||||
let client_metadata = HashMap::from([
|
||||
("fiber_run_id".to_string(), "fiber-start-123".to_string()),
|
||||
("origin".to_string(), "gaas".to_string()),
|
||||
("thread_source".to_string(), "client-supplied".to_string()),
|
||||
]);
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
@@ -110,7 +112,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
|
||||
.expect("x-codex-turn-metadata header should be present");
|
||||
assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123"));
|
||||
assert_eq!(metadata["origin"].as_str(), Some("gaas"));
|
||||
assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied"));
|
||||
assert_eq!(metadata["thread_source"].as_str(), Some("automation"));
|
||||
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
|
||||
assert!(metadata.get("installation_id").is_some());
|
||||
assert!(metadata.get("session_id").is_some());
|
||||
@@ -300,7 +302,7 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> {
|
||||
async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
@@ -321,6 +323,8 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -
|
||||
/*supports_websockets*/ false,
|
||||
)?;
|
||||
|
||||
let root_thread_id = CoreThreadId::new();
|
||||
let root_thread_id_str = root_thread_id.to_string();
|
||||
let parent_thread_id = CoreThreadId::new();
|
||||
let parent_thread_id_str = parent_thread_id.to_string();
|
||||
let subagent_thread_id = create_fake_parented_rollout_with_source(
|
||||
@@ -331,6 +335,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())),
|
||||
root_thread_id.into(),
|
||||
parent_thread_id,
|
||||
)?;
|
||||
|
||||
@@ -350,6 +355,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -
|
||||
.await??;
|
||||
let ThreadResumeResponse { thread, .. } = to_response::<ThreadResumeResponse>(resume_resp)?;
|
||||
assert_eq!(thread.id, subagent_thread_id);
|
||||
assert_eq!(thread.session_id, root_thread_id_str);
|
||||
assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone()));
|
||||
assert_eq!(
|
||||
thread.source,
|
||||
@@ -390,6 +396,10 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -
|
||||
Some(parent_thread_id_str.as_str())
|
||||
);
|
||||
assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian"));
|
||||
assert_eq!(
|
||||
metadata["session_id"].as_str(),
|
||||
Some(thread.session_id.as_str())
|
||||
);
|
||||
assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str()));
|
||||
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
|
||||
assert!(metadata.get("forked_from_thread_id").is_none());
|
||||
@@ -553,7 +563,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_req = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.send_thread_start_request(ThreadStartParams {
|
||||
thread_source: Some(ThreadSource::Feature("automation".to_string())),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
@@ -611,6 +624,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body
|
||||
.expect("websocket x-codex-turn-metadata client metadata should be present");
|
||||
assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123"));
|
||||
assert_eq!(metadata["origin"].as_str(), Some("gaas"));
|
||||
assert_eq!(metadata["thread_source"].as_str(), Some("automation"));
|
||||
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
|
||||
assert!(metadata.get("session_id").is_some());
|
||||
assert_eq!(
|
||||
|
||||
@@ -134,12 +134,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
|
||||
text: "REMOTE_COMPACT_SUMMARY".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
ResponseItem::Compaction {
|
||||
id: None,
|
||||
encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(),
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
},
|
||||
];
|
||||
let compact_mock = responses::mount_compact_json_once(
|
||||
|
||||
@@ -38,6 +38,10 @@ use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::MockServer;
|
||||
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
|
||||
// subprocesses or processing test RPCs under load.
|
||||
#[cfg(any(target_os = "macos", windows))]
|
||||
@@ -550,15 +554,19 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensures dynamic tool call responses can include structured content items.
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
|
||||
let call_id = "dyn-call-items-1";
|
||||
struct PendingDynamicToolCall {
|
||||
mcp: TestAppServer,
|
||||
server: MockServer,
|
||||
request_id: RequestId,
|
||||
params: DynamicToolCallParams,
|
||||
}
|
||||
|
||||
async fn start_function_dynamic_tool_call(call_id: &str) -> Result<PendingDynamicToolCall> {
|
||||
let tool_name = "demo_tool";
|
||||
let tool_args = json!({ "city": "Paris" });
|
||||
let tool_call_arguments = serde_json::to_string(&tool_args)?;
|
||||
|
||||
let responses = vec![
|
||||
let response_sequence = vec![
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
|
||||
@@ -566,7 +574,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
]),
|
||||
create_final_assistant_message_sse_response("Done")?,
|
||||
];
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
let server = create_mock_responses_server_sequence_unchecked(response_sequence).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri())?;
|
||||
@@ -630,27 +638,46 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
mcp.read_stream_until_request_message(),
|
||||
)
|
||||
.await??;
|
||||
let (request_id, params) = match request {
|
||||
let (request_id, actual_params) = match request {
|
||||
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
|
||||
other => panic!("expected DynamicToolCall request, got {other:?}"),
|
||||
};
|
||||
|
||||
let expected = DynamicToolCallParams {
|
||||
let params = DynamicToolCallParams {
|
||||
thread_id,
|
||||
turn_id: turn_id.clone(),
|
||||
turn_id,
|
||||
call_id: call_id.to_string(),
|
||||
namespace: None,
|
||||
tool: tool_name.to_string(),
|
||||
arguments: tool_args,
|
||||
};
|
||||
assert_eq!(params, expected);
|
||||
assert_eq!(actual_params, params);
|
||||
|
||||
Ok(PendingDynamicToolCall {
|
||||
mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/// Ensures dynamic tool call responses can include structured content items.
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> {
|
||||
let call_id = "dyn-call-items-1";
|
||||
let PendingDynamicToolCall {
|
||||
mut mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
} = start_function_dynamic_tool_call(call_id).await?;
|
||||
|
||||
let response_content_items = vec![
|
||||
DynamicToolCallOutputContentItem::InputText {
|
||||
text: "dynamic-ok".to_string(),
|
||||
},
|
||||
DynamicToolCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
image_url: TINY_PNG_DATA_URL.to_string(),
|
||||
},
|
||||
];
|
||||
let content_items = response_content_items
|
||||
@@ -676,8 +703,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
.await?;
|
||||
|
||||
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
||||
assert_eq!(completed.thread_id, expected.thread_id.clone());
|
||||
assert_eq!(completed.turn_id, turn_id);
|
||||
assert_eq!(completed.thread_id, params.thread_id);
|
||||
assert_eq!(completed.turn_id, params.turn_id);
|
||||
let ThreadItem::DynamicToolCall {
|
||||
status,
|
||||
content_items: completed_content_items,
|
||||
@@ -695,7 +722,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
text: "dynamic-ok".to_string(),
|
||||
},
|
||||
DynamicToolCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
image_url: TINY_PNG_DATA_URL.to_string(),
|
||||
},
|
||||
])
|
||||
);
|
||||
@@ -721,7 +748,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,AAA",
|
||||
"image_url": TINY_PNG_DATA_URL,
|
||||
"detail": "high"
|
||||
}
|
||||
])
|
||||
@@ -744,6 +771,62 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Result<()> {
|
||||
let call_id = "dyn-call-remote-image";
|
||||
let PendingDynamicToolCall {
|
||||
mut mcp,
|
||||
server,
|
||||
request_id,
|
||||
params,
|
||||
} = start_function_dynamic_tool_call(call_id).await?;
|
||||
|
||||
let response = DynamicToolCallResponse {
|
||||
content_items: vec![DynamicToolCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/tool.png".to_string(),
|
||||
}],
|
||||
success: true,
|
||||
};
|
||||
mcp.send_response(request_id, serde_json::to_value(response)?)
|
||||
.await?;
|
||||
|
||||
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
|
||||
assert_eq!(completed.thread_id, params.thread_id);
|
||||
assert_eq!(completed.turn_id, params.turn_id);
|
||||
let ThreadItem::DynamicToolCall {
|
||||
status,
|
||||
content_items,
|
||||
success,
|
||||
..
|
||||
} = completed.item
|
||||
else {
|
||||
panic!("expected dynamic tool call item");
|
||||
};
|
||||
assert_eq!(status, DynamicToolCallStatus::Failed);
|
||||
assert_eq!(
|
||||
content_items,
|
||||
Some(vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: REMOTE_IMAGE_URL_ERROR.to_string(),
|
||||
}])
|
||||
);
|
||||
assert_eq!(success, Some(false));
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let output = responses_bodies(&server)
|
||||
.await?
|
||||
.iter()
|
||||
.find_map(|body| function_call_output_raw_output(body, call_id))
|
||||
.context("expected function_call_output output in follow-up request")?;
|
||||
assert_eq!(output, json!(REMOTE_IMAGE_URL_ERROR));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn responses_bodies(server: &MockServer) -> Result<Vec<Value>> {
|
||||
let requests = server
|
||||
.received_requests()
|
||||
|
||||
@@ -28,13 +28,13 @@ use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const RESULT: &str = "cG5n";
|
||||
const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
const TINY_PNG_BYTES: &[u8] = &[
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
|
||||
0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1,
|
||||
122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0,
|
||||
5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
];
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum ImagegenTestMode {
|
||||
@@ -116,7 +116,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul
|
||||
assert_eq!(status, "completed");
|
||||
assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale"));
|
||||
assert_eq!(result, RESULT);
|
||||
assert_eq!(std::fs::read(&saved_path)?, b"png");
|
||||
assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES);
|
||||
|
||||
let requests = response_mock.requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
@@ -256,7 +256,7 @@ async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()>
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> {
|
||||
let image_url = "https://example.com/reference.png";
|
||||
let image_url = TINY_PNG_DATA_URL;
|
||||
let edit_request = run_image_edit_test(|_| {
|
||||
Ok((
|
||||
json!({
|
||||
|
||||
@@ -52,6 +52,7 @@ mod remote_control;
|
||||
mod remote_thread_store;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod request_validation;
|
||||
mod review;
|
||||
mod safety_check_downgrade;
|
||||
mod skills_list;
|
||||
|
||||
@@ -60,22 +60,27 @@ description = "Inspect without writes."
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: "audit".to_string(),
|
||||
description: Some("Inspect without writes.".to_string()),
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: "dev".to_string(),
|
||||
description: Some("Day-to-day coding work.".to_string()),
|
||||
allowed: true,
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
@@ -126,14 +131,17 @@ description = "Project-scoped profile."
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
],
|
||||
next_cursor: Some("3".to_string()),
|
||||
@@ -155,6 +163,7 @@ description = "Project-scoped profile."
|
||||
data: vec![PermissionProfileSummary {
|
||||
id: "project".to_string(),
|
||||
description: Some("Project-scoped profile.".to_string()),
|
||||
allowed: true,
|
||||
}],
|
||||
next_cursor: None,
|
||||
}
|
||||
@@ -200,18 +209,22 @@ description = "Project-scoped profile."
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(),
|
||||
description: None,
|
||||
allowed: true,
|
||||
},
|
||||
PermissionProfileSummary {
|
||||
id: "project".to_string(),
|
||||
description: Some("Project-scoped profile.".to_string()),
|
||||
allowed: true,
|
||||
},
|
||||
],
|
||||
next_cursor: None,
|
||||
|
||||
@@ -753,7 +753,9 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
composer_icon: None,
|
||||
composer_icon_url: None,
|
||||
logo: None,
|
||||
logo_dark: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
screenshots: Vec::new(),
|
||||
screenshot_urls: Vec::new(),
|
||||
}),
|
||||
@@ -1761,6 +1763,14 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated-remote"]"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
@@ -1854,7 +1864,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
cached_plugin_ids,
|
||||
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
|
||||
);
|
||||
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
|
||||
assert_eq!(
|
||||
response.featured_plugin_ids,
|
||||
vec!["linear@openai-curated-remote".to_string()]
|
||||
);
|
||||
assert!(
|
||||
!server
|
||||
.received_requests()
|
||||
|
||||
@@ -162,7 +162,8 @@ apps = true
|
||||
"short_description": "Example plugin",
|
||||
"capabilities": [],
|
||||
"default_prompt": "Use the legacy example prompt",
|
||||
"default_prompts": []
|
||||
"default_prompts": [],
|
||||
"logo_url_dark": "https://example.com/example-plugin-dark.png"
|
||||
},
|
||||
"skills": [],
|
||||
"mcp_servers": [
|
||||
@@ -273,6 +274,15 @@ apps = true
|
||||
.and_then(|interface| interface.default_prompt.clone()),
|
||||
Some(vec!["Use the legacy example prompt".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.logo_url_dark.as_deref()),
|
||||
Some("https://example.com/example-plugin-dark.png")
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.mcp_servers,
|
||||
vec!["other-server".to_string()]
|
||||
@@ -1330,6 +1340,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
|
||||
"brandColor": "#3B82F6",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
"logoDark": "./assets/logo-dark.png",
|
||||
"screenshots": ["./assets/screenshot1.png"]
|
||||
}
|
||||
}"##,
|
||||
@@ -1508,6 +1519,18 @@ enabled = false
|
||||
"Find my next action".to_string()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.summary
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.logo_dark.as_ref()),
|
||||
Some(
|
||||
&AbsolutePathBuf::try_from(plugin_root.join("assets/logo-dark.png"))
|
||||
.expect("absolute dark logo path")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.summary.keywords,
|
||||
vec!["api-key".to_string(), "developer tools".to_string()]
|
||||
|
||||
@@ -1357,7 +1357,9 @@ fn expected_plugin_interface() -> PluginInterface {
|
||||
composer_icon: None,
|
||||
composer_icon_url: None,
|
||||
logo: None,
|
||||
logo_dark: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
screenshots: Vec::new(),
|
||||
screenshot_urls: Vec::new(),
|
||||
}
|
||||
|
||||
@@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> {
|
||||
"rate limit reset consume timed out"
|
||||
);
|
||||
|
||||
let account_error = read_error_response(&mut mcp, account_id).await?;
|
||||
assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
account_error.error.message,
|
||||
"email and plan type are required for chatgpt authentication"
|
||||
);
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(account_id)),
|
||||
)
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist
|
||||
let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
|
||||
thread_store
|
||||
.create_thread(StoreCreateThreadParams {
|
||||
session_id: unloaded_thread_id.into(),
|
||||
thread_id: unloaded_thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
use anyhow::Result;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadStartParams;
|
||||
use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REMOTE_IMAGE_URL_ERROR: &str =
|
||||
"remote image URLs are not supported; use an inline data URL instead";
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_handlers_reject_remote_image_urls() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_mock_responses_config_toml_with_chatgpt_base_url(
|
||||
codex_home.path(),
|
||||
"http://localhost/unused",
|
||||
"http://localhost/unused",
|
||||
)?;
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let thread_request_id = mcp
|
||||
.send_thread_start_request(ThreadStartParams::default())
|
||||
.await?;
|
||||
let thread_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_response)?;
|
||||
let thread_id = thread.id;
|
||||
|
||||
let remote_tool_output = serde_json::to_value(ResponseItem::FunctionCallOutput {
|
||||
id: None,
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/tool.png".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
})?;
|
||||
let requests = [
|
||||
(
|
||||
"turn/start",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"input": [{
|
||||
"type": "image",
|
||||
"url": "HTTP://example.com/start.png",
|
||||
"detail": "high"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"turn/steer",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"expectedTurnId": "turn-id",
|
||||
"input": [{
|
||||
"type": "image",
|
||||
"url": "https://example.com/steer.png",
|
||||
"detail": "high"
|
||||
}]
|
||||
}),
|
||||
),
|
||||
(
|
||||
"thread/inject_items",
|
||||
json!({
|
||||
"threadId": thread_id,
|
||||
"items": [remote_tool_output]
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
for (method, params) in requests {
|
||||
let request_id = mcp.send_raw_request(method, Some(params)).await?;
|
||||
let actual: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let expected = JSONRPCError {
|
||||
id: RequestId::Integer(request_id),
|
||||
error: JSONRPCErrorError {
|
||||
code: -32600,
|
||||
data: None,
|
||||
message: REMOTE_IMAGE_URL_ERROR.to_string(),
|
||||
},
|
||||
};
|
||||
assert_eq!(actual, expected, "unexpected response for {method}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +60,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu
|
||||
text: injected_text.to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
|
||||
let inject_req = mcp
|
||||
@@ -86,7 +86,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu
|
||||
resumed_history
|
||||
.history
|
||||
.iter()
|
||||
.any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)),
|
||||
.any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if responses::strip_metadata(response_item.clone()) == injected_item)),
|
||||
"injected item should be persisted in rollout history"
|
||||
);
|
||||
|
||||
@@ -198,7 +198,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<()
|
||||
text: "Injected after first turn".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
};
|
||||
let injected_value = serde_json::to_value(&injected_item)?;
|
||||
|
||||
|
||||
@@ -1252,6 +1252,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> {
|
||||
Some("mock_provider"),
|
||||
/*git_info*/ None,
|
||||
CoreSessionSource::SubAgent(SubAgentSource::Review),
|
||||
parent_thread_id.into(),
|
||||
parent_thread_id,
|
||||
)?;
|
||||
let compact_id = create_fake_rollout_with_source(
|
||||
|
||||
@@ -1357,6 +1357,7 @@ async fn seed_pathless_store_thread(
|
||||
) -> Result<()> {
|
||||
store
|
||||
.create_thread(CreateThreadParams {
|
||||
session_id: thread_id.into(),
|
||||
thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: None,
|
||||
|
||||
@@ -2054,6 +2054,7 @@ stream_max_retries = 0
|
||||
let rollout_dir = rollout_path.parent().expect("rollout parent directory");
|
||||
std::fs::create_dir_all(rollout_dir)?;
|
||||
let session_meta = SessionMeta {
|
||||
session_id: conversation_id.into(),
|
||||
id: conversation_id,
|
||||
forked_from_id: None,
|
||||
parent_thread_id: None,
|
||||
@@ -2588,7 +2589,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> {
|
||||
text: "history override".to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}]),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -2735,6 +2736,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"session_id": thread_uuid,
|
||||
"id": thread_uuid,
|
||||
"timestamp": "2025-01-01T00:00:00Z",
|
||||
"cwd": codex_home.path(),
|
||||
@@ -3596,7 +3598,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> {
|
||||
text: history_text.to_string(),
|
||||
}],
|
||||
phase: None,
|
||||
metadata: None,
|
||||
internal_chat_message_metadata_passthrough: None,
|
||||
}];
|
||||
|
||||
// Resume with explicit history and override the model.
|
||||
|
||||
@@ -117,10 +117,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re
|
||||
write_mock_responses_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
&BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
&BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
/*auto_compact_limit*/ 200_000,
|
||||
/*requires_openai_auth*/ None,
|
||||
"mock_provider",
|
||||
@@ -158,7 +155,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re
|
||||
assert_eq!(updated.thread_id, thread.id);
|
||||
assert_eq!(
|
||||
updated.thread_settings.multi_agent_mode,
|
||||
Some(MultiAgentMode::Proactive)
|
||||
MultiAgentMode::Proactive
|
||||
);
|
||||
|
||||
start_text_turn(&mut mcp, thread.id).await?;
|
||||
|
||||
@@ -208,6 +208,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> {
|
||||
let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?;
|
||||
store
|
||||
.create_thread(CreateThreadParams {
|
||||
session_id: thread_id.into(),
|
||||
thread_id,
|
||||
extra_config: None,
|
||||
forked_from_id: Some(parent_thread_id),
|
||||
|
||||
@@ -100,12 +100,14 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const TEST_ORIGINATOR: &str = "codex_vscode";
|
||||
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
|
||||
const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration";
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const TINY_PNG_BYTES: &[u8] = &[
|
||||
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0,
|
||||
0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1,
|
||||
122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
|
||||
];
|
||||
const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
String::from_utf8(req.body.clone())
|
||||
@@ -888,7 +890,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> {
|
||||
thread_id: thread.id.clone(),
|
||||
client_user_message_id: None,
|
||||
input: vec![V2UserInput::Image {
|
||||
url: "https://example.com/a.png".to_string(),
|
||||
url: TINY_PNG_DATA_URL.to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
responsesapi_client_metadata: Some(HashMap::from([(
|
||||
@@ -1768,10 +1770,7 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> {
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
&BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
@@ -1845,10 +1844,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
&BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
)?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
@@ -1871,7 +1867,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
|
||||
multi_agent_mode,
|
||||
..
|
||||
} = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive));
|
||||
assert_eq!(multi_agent_mode, MultiAgentMode::Proactive);
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
@@ -1911,27 +1907,29 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> {
|
||||
async fn thread_start_reports_multi_agent_mode() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let cases = [
|
||||
(
|
||||
BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
MultiAgentMode::Proactive,
|
||||
),
|
||||
(
|
||||
BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
Some(MultiAgentMode::None),
|
||||
MultiAgentMode::None,
|
||||
),
|
||||
(
|
||||
BTreeMap::new(),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
Some(MultiAgentMode::Proactive),
|
||||
MultiAgentMode::Proactive,
|
||||
),
|
||||
(
|
||||
BTreeMap::from([
|
||||
(Feature::MultiAgentV2, true),
|
||||
(Feature::MultiAgentMode, true),
|
||||
]),
|
||||
None,
|
||||
BTreeMap::from([(Feature::MultiAgentV2, true)]),
|
||||
None,
|
||||
MultiAgentMode::ExplicitRequestOnly,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -3653,7 +3651,12 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> {
|
||||
|req: &wiremock::Request| body_contains(req, PARENT_PROMPT),
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp-parent-1"),
|
||||
responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
|
||||
responses::ev_function_call_with_namespace(
|
||||
SPAWN_CALL_ID,
|
||||
MULTI_AGENT_V2_NAMESPACE,
|
||||
"spawn_agent",
|
||||
&spawn_args,
|
||||
),
|
||||
responses::ev_completed("resp-parent-1"),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -156,8 +156,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
search_body["input"]
|
||||
.as_array()
|
||||
.context("search input should be an array")?
|
||||
.last(),
|
||||
Some(&json!({
|
||||
.last()
|
||||
.cloned()
|
||||
.map(responses::strip_metadata_from_json),
|
||||
Some(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "Search the web"}],
|
||||
@@ -165,7 +167,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
requests[1].function_call_output(call_id),
|
||||
responses::strip_metadata_from_json(requests[1].function_call_output(call_id)),
|
||||
json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
|
||||
Reference in New Issue
Block a user