Merge 05544a1e27 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-25 09:10:42 -08:00
committed by GitHub
102 changed files with 6559 additions and 1333 deletions
+128 -102
View File
File diff suppressed because one or more lines are too long
+360 -492
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -58,10 +58,12 @@ members = [
"utils/approval-presets",
"utils/oss",
"utils/fuzzy-match",
"utils/stream-parser",
"codex-client",
"codex-api",
"state",
"codex-experimental-api-macros",
"test-macros",
]
resolver = "2"
@@ -116,6 +118,7 @@ codex-shell-command = { path = "shell-command" }
codex-shell-escalation = { path = "shell-escalation" }
codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-test-macros = { path = "test-macros" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-tui = { path = "tui" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
@@ -135,6 +138,7 @@ codex-utils-rustls-provider = { path = "utils/rustls-provider" }
codex-utils-sandbox-summary = { path = "utils/sandbox-summary" }
codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" }
codex-utils-string = { path = "utils/string" }
codex-utils-stream-parser = { path = "utils/stream-parser" }
codex-windows-sandbox = { path = "windows-sandbox-rs" }
core_test_support = { path = "core/tests/common" }
mcp_test_support = { path = "mcp-server/tests/common" }
@@ -174,6 +178,7 @@ env_logger = "0.11.9"
eventsource-stream = "0.2.3"
futures = { version = "0.3", default-features = false }
globset = "0.4"
gethostname = "1.1.0"
http = "1.3.1"
icu_decimal = "2.1"
icu_locale_core = "2.1"
@@ -376,6 +376,70 @@
},
"type": "object"
},
"ExternalAgentConfigDetectParams": {
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"type": "object"
},
"ExternalAgentConfigImportParams": {
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"FeedbackUploadParams": {
"properties": {
"classification": {
@@ -1920,6 +1984,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{
@@ -2190,6 +2261,12 @@
"type": "null"
}
]
},
"serviceName": {
"type": [
"string",
"null"
]
}
},
"type": "object"
@@ -3390,6 +3467,54 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/detect"
],
"title": "ExternalAgentConfig/detectRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigDetectParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/detectRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import"
],
"title": "ExternalAgentConfig/importRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExternalAgentConfigImportParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1169,6 +1169,54 @@
"title": "Config/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/detect"
],
"title": "ExternalAgentConfig/detectRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExternalAgentConfigDetectParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/detectRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"externalAgentConfig/import"
],
"title": "ExternalAgentConfig/importRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExternalAgentConfigImportParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExternalAgentConfig/importRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9145,6 +9193,95 @@
}
]
},
"ExternalAgentConfigDetectParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"title": "ExternalAgentConfigDetectParams",
"type": "object"
},
"ExternalAgentConfigDetectResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"items": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"items"
],
"title": "ExternalAgentConfigDetectResponse",
"type": "object"
},
"ExternalAgentConfigImportParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"title": "ExternalAgentConfigImportParams",
"type": "object"
},
"ExternalAgentConfigImportResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExternalAgentConfigImportResponse",
"type": "object"
},
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/v2/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
},
"FeedbackUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12916,6 +13053,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{
@@ -13343,6 +13487,12 @@
"type": "null"
}
]
},
"serviceName": {
"type": [
"string",
"null"
]
}
},
"title": "ThreadStartParams",
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cwds": {
"description": "Zero or more working directories to include for repo-scoped detection.",
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
},
"includeHome": {
"description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).",
"type": "boolean"
}
},
"title": "ExternalAgentConfigDetectParams",
"type": "object"
}
@@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
}
},
"properties": {
"items": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"items"
],
"title": "ExternalAgentConfigDetectResponse",
"type": "object"
}
@@ -0,0 +1,49 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExternalAgentConfigMigrationItem": {
"properties": {
"cwd": {
"description": "Null or empty means home-scoped migration; non-empty means repo-scoped migration.",
"type": [
"string",
"null"
]
},
"description": {
"type": "string"
},
"itemType": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItemType"
}
},
"required": [
"description",
"itemType"
],
"type": "object"
},
"ExternalAgentConfigMigrationItemType": {
"enum": [
"AGENTS_MD",
"CONFIG",
"SKILLS",
"MCP_SERVER_CONFIG"
],
"type": "string"
}
},
"properties": {
"migrationItems": {
"items": {
"$ref": "#/definitions/ExternalAgentConfigMigrationItem"
},
"type": "array"
}
},
"required": [
"migrationItems"
],
"title": "ExternalAgentConfigImportParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExternalAgentConfigImportResponse",
"type": "object"
}
@@ -65,6 +65,13 @@
"null"
]
},
"searchTerm": {
"description": "Optional substring filter for the extracted thread title.",
"type": [
"string",
"null"
]
},
"sortKey": {
"anyOf": [
{
@@ -150,6 +150,12 @@
"type": "null"
}
]
},
"serviceName": {
"type": [
"string",
"null"
]
}
},
"title": "ThreadStartParams",
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigDetectParams = {
/**
* If true, include detection under the user's home (~/.claude, ~/.codex, etc.).
*/
includeHome?: boolean,
/**
* Zero or more working directories to include for repo-scoped detection.
*/
cwds?: Array<string> | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type ExternalAgentConfigDetectResponse = { items: Array<ExternalAgentConfigMigrationItem>, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigImportResponse = Record<string, never>;
@@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type ExternalAgentConfigMigrationItem = { itemType: ExternalAgentConfigMigrationItemType, description: string,
/**
* Null or empty means home-scoped migration; non-empty means repo-scoped migration.
*/
cwd: string | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExternalAgentConfigMigrationItemType = "AGENTS_MD" | "CONFIG" | "SKILLS" | "MCP_SERVER_CONFIG";
@@ -36,4 +36,8 @@ archived?: boolean | null,
* Optional cwd filter; when set, only threads whose session cwd exactly
* matches this path are returned.
*/
cwd?: string | null, };
cwd?: string | null,
/**
* Optional substring filter for the extracted thread title.
*/
searchTerm?: string | null, };
@@ -6,7 +6,7 @@ import type { JsonValue } from "../serde_json/JsonValue";
import type { AskForApproval } from "./AskForApproval";
import type { SandboxMode } from "./SandboxMode";
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, /**
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/
@@ -68,6 +68,12 @@ export type { ExperimentalFeature } from "./ExperimentalFeature";
export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams";
export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse";
export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
export type { ExternalAgentConfigDetectParams } from "./ExternalAgentConfigDetectParams";
export type { ExternalAgentConfigDetectResponse } from "./ExternalAgentConfigDetectResponse";
export type { ExternalAgentConfigImportParams } from "./ExternalAgentConfigImportParams";
export type { ExternalAgentConfigImportResponse } from "./ExternalAgentConfigImportResponse";
export type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem";
export type { ExternalAgentConfigMigrationItemType } from "./ExternalAgentConfigMigrationItemType";
export type { FeedbackUploadParams } from "./FeedbackUploadParams";
export type { FeedbackUploadResponse } from "./FeedbackUploadResponse";
export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision";
@@ -350,6 +350,14 @@ client_request_definitions! {
params: v2::ConfigReadParams,
response: v2::ConfigReadResponse,
},
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
params: v2::ExternalAgentConfigDetectParams,
response: v2::ExternalAgentConfigDetectResponse,
},
ExternalAgentConfigImport => "externalAgentConfig/import" {
params: v2::ExternalAgentConfigImportParams,
response: v2::ExternalAgentConfigImportResponse,
},
ConfigValueWrite => "config/value/write" {
params: v2::ConfigValueWriteParams,
response: v2::ConfigWriteResponse,
@@ -641,6 +641,64 @@ pub struct ConfigRequirementsReadResponse {
pub requirements: Option<ConfigRequirements>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum ExternalAgentConfigMigrationItemType {
#[serde(rename = "AGENTS_MD")]
#[ts(rename = "AGENTS_MD")]
AgentsMd,
#[serde(rename = "CONFIG")]
#[ts(rename = "CONFIG")]
Config,
#[serde(rename = "SKILLS")]
#[ts(rename = "SKILLS")]
Skills,
#[serde(rename = "MCP_SERVER_CONFIG")]
#[ts(rename = "MCP_SERVER_CONFIG")]
McpServerConfig,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
/// Null or empty means home-scoped migration; non-empty means repo-scoped migration.
pub cwd: Option<PathBuf>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigDetectResponse {
pub items: Vec<ExternalAgentConfigMigrationItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigDetectParams {
/// If true, include detection under the user's home (~/.claude, ~/.codex, etc.).
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub include_home: bool,
/// Zero or more working directories to include for repo-scoped detection.
#[ts(optional = nullable)]
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportParams {
pub migration_items: Vec<ExternalAgentConfigMigrationItem>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExternalAgentConfigImportResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1684,6 +1742,8 @@ pub struct ThreadStartParams {
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
#[ts(optional = nullable)]
pub service_name: Option<String>,
#[ts(optional = nullable)]
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
pub developer_instructions: Option<String>,
@@ -1984,6 +2044,9 @@ pub struct ThreadListParams {
/// matches this path are returned.
#[ts(optional = nullable)]
pub cwd: Option<String>,
/// Optional substring filter for the extracted thread title.
#[ts(optional = nullable)]
pub search_term: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
@@ -916,6 +916,7 @@ fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u32) ->
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})?;
println!("< thread/list response: {response:?}");
+1
View File
@@ -65,6 +65,7 @@ axum = { workspace = true, default-features = false, features = [
base64 = { workspace = true }
codex-execpolicy = { workspace = true }
core_test_support = { workspace = true }
codex-state = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
+5 -1
View File
@@ -122,7 +122,7 @@ Example with notification opt-out:
- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread.
- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it.
- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` and auto-subscribes you to turn/item events for the new thread.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, and `cwd` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `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/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
@@ -153,6 +153,8 @@ Example with notification opt-out:
- `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `config/read` — fetch the effective config on disk after resolving config layering.
- `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home).
- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home).
- `config/value/write` — write a single config key/value to the user's config.toml on disk.
- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk.
- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), `enforceResidency`, and `network` constraints.
@@ -170,6 +172,7 @@ Start a fresh thread when you need a new Codex conversation.
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
"personality": "friendly",
"serviceName": "my_app_server_client", // optional metrics tag (`service_name`)
// Experimental: requires opt-in
"dynamicTools": [
{
@@ -229,6 +232,7 @@ Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `per
- `sourceKinds` — restrict results to specific sources; omit or pass `[]` for interactive sessions only (`cli`, `vscode`).
- `archived` — when `true`, list archived threads only. When `false` or `null`, list non-archived threads (default).
- `cwd` — restrict results to threads whose session cwd exactly matches this path.
- `searchTerm` — restrict results to threads whose extracted title contains this substring (case-sensitive).
- Responses include `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available.
Example:
@@ -190,6 +190,7 @@ use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigService;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::config::types::McpServerTransportConfig;
@@ -290,6 +291,7 @@ struct ThreadListFilters {
source_kinds: Option<Vec<ThreadSourceKind>>,
archived: bool,
cwd: Option<PathBuf>,
search_term: Option<String>,
}
// Duration before a ChatGPT login attempt is abandoned.
@@ -821,6 +823,10 @@ impl CodexMessageProcessor {
ClientRequest::ConfigRequirementsRead { .. } => {
warn!("ConfigRequirementsRead request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::ExternalAgentConfigDetect { .. }
| ClientRequest::ExternalAgentConfigImport { .. } => {
warn!("ExternalAgentConfig request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::GetAccountRateLimits {
request_id,
params: _,
@@ -1746,6 +1752,7 @@ impl CodexMessageProcessor {
None,
None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
{
@@ -1956,6 +1963,7 @@ impl CodexMessageProcessor {
approval_policy,
sandbox,
config,
service_name,
base_instructions,
developer_instructions,
dynamic_tools,
@@ -2023,7 +2031,12 @@ impl CodexMessageProcessor {
match self
.thread_manager
.start_thread_with_tools(config, core_dynamic_tools, persist_extended_history)
.start_thread_with_tools_and_service_name(
config,
core_dynamic_tools,
persist_extended_history,
service_name,
)
.await
{
Ok(new_conv) => {
@@ -2531,6 +2544,7 @@ impl CodexMessageProcessor {
source_kinds,
archived,
cwd,
search_term,
} = params;
let requested_page_size = limit
@@ -2551,6 +2565,7 @@ impl CodexMessageProcessor {
source_kinds,
archived: archived.unwrap_or(false),
cwd: cwd.map(PathBuf::from),
search_term,
},
)
.await
@@ -3612,6 +3627,7 @@ impl CodexMessageProcessor {
source_kinds: None,
archived: false,
cwd: None,
search_term: None,
},
)
.await
@@ -3638,6 +3654,7 @@ impl CodexMessageProcessor {
source_kinds,
archived,
cwd,
search_term,
} = filters;
let mut cursor_obj: Option<RolloutCursor> = match cursor.as_ref() {
Some(cursor_str) => {
@@ -3680,6 +3697,7 @@ impl CodexMessageProcessor {
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
search_term.as_deref(),
)
.await
.map_err(|err| JSONRPCErrorError {
@@ -3696,6 +3714,7 @@ impl CodexMessageProcessor {
allowed_sources,
model_provider_filter.as_deref(),
fallback_provider.as_str(),
search_term.as_deref(),
)
.await
.map_err(|err| JSONRPCErrorError {
@@ -0,0 +1,106 @@
use crate::error_code::INTERNAL_ERROR_CODE;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_core::external_agent_config::ExternalAgentConfigDetectOptions;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use codex_core::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use codex_core::external_agent_config::ExternalAgentConfigService;
use std::io;
use std::path::PathBuf;
#[derive(Clone)]
pub(crate) struct ExternalAgentConfigApi {
migration_service: ExternalAgentConfigService,
}
impl ExternalAgentConfigApi {
pub(crate) fn new(codex_home: PathBuf) -> Self {
Self {
migration_service: ExternalAgentConfigService::new(codex_home),
}
}
pub(crate) async fn detect(
&self,
params: ExternalAgentConfigDetectParams,
) -> Result<ExternalAgentConfigDetectResponse, JSONRPCErrorError> {
let items = self
.migration_service
.detect(ExternalAgentConfigDetectOptions {
include_home: params.include_home,
cwds: params.cwds,
})
.map_err(map_io_error)?;
Ok(ExternalAgentConfigDetectResponse {
items: items
.into_iter()
.map(|migration_item| ExternalAgentConfigMigrationItem {
item_type: match migration_item.item_type {
CoreMigrationItemType::Config => {
ExternalAgentConfigMigrationItemType::Config
}
CoreMigrationItemType::Skills => {
ExternalAgentConfigMigrationItemType::Skills
}
CoreMigrationItemType::AgentsMd => {
ExternalAgentConfigMigrationItemType::AgentsMd
}
CoreMigrationItemType::McpServerConfig => {
ExternalAgentConfigMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
})
.collect(),
})
}
pub(crate) async fn import(
&self,
params: ExternalAgentConfigImportParams,
) -> Result<ExternalAgentConfigImportResponse, JSONRPCErrorError> {
self.migration_service
.import(
params
.migration_items
.into_iter()
.map(|migration_item| CoreMigrationItem {
item_type: match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
CoreMigrationItemType::Config
}
ExternalAgentConfigMigrationItemType::Skills => {
CoreMigrationItemType::Skills
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
CoreMigrationItemType::AgentsMd
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {
CoreMigrationItemType::McpServerConfig
}
},
description: migration_item.description,
cwd: migration_item.cwd,
})
.collect(),
)
.map_err(map_io_error)?;
Ok(ExternalAgentConfigImportResponse {})
}
}
fn map_io_error(err: io::Error) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: err.to_string(),
data: None,
}
}
+1
View File
@@ -57,6 +57,7 @@ mod codex_message_processor;
mod config_api;
mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
mod filters;
mod fuzzy_file_search;
mod message_processor;
@@ -8,6 +8,7 @@ use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -22,6 +23,8 @@ use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -126,6 +129,7 @@ pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
external_agent_config_api: ExternalAgentConfigApi,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
@@ -197,11 +201,13 @@ impl MessageProcessor {
loader_overrides,
cloud_requirements,
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
Self {
outgoing,
codex_message_processor,
config_api,
external_agent_config_api,
config,
config_warnings: Arc::new(config_warnings),
}
@@ -363,6 +369,26 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::ExternalAgentConfigDetect { request_id, params } => {
self.handle_external_agent_config_detect(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ExternalAgentConfigImport { request_id, params } => {
self.handle_external_agent_config_import(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(
ConnectionRequestId {
@@ -492,4 +518,26 @@ impl MessageProcessor {
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_external_agent_config_detect(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigDetectParams,
) {
match self.external_agent_config_api.detect(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
async fn handle_external_agent_config_import(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigImportParams,
) {
match self.external_agent_config_api.import(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
}
}
}
@@ -78,6 +78,7 @@ async fn list_threads_with_sort(
source_kinds,
archived,
cwd: None,
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -491,6 +492,7 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
source_kinds: None,
archived: None,
cwd: Some(target_cwd.to_string_lossy().into_owned()),
search_term: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
@@ -511,6 +513,86 @@ async fn thread_list_respects_cwd_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_list_respects_search_term_filter() -> Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"
model = "mock-model"
approval_policy = "never"
suppress_unstable_features_warning = true
[features]
sqlite = true
"#,
)?;
let older_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T10-00-00",
"2025-01-02T10:00:00Z",
"match: needle",
Some("mock_provider"),
None,
)?;
let _non_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T11-00-00",
"2025-01-02T11:00:00Z",
"no hit here",
Some("mock_provider"),
None,
)?;
let newer_match = create_fake_rollout(
codex_home.path(),
"2025-01-02T12-00-00",
"2025-01-02T12:00:00Z",
"needle suffix",
Some("mock_provider"),
None,
)?;
// `thread/list` only applies `search_term` on the sqlite path. In this test we
// create rollouts manually, so we must also create the sqlite DB and mark backfill
// complete; otherwise app-server will permanently use filesystem fallback.
let state_db = codex_state::StateRuntime::init(
codex_home.path().to_path_buf(),
"mock_provider".into(),
None,
)
.await?;
state_db.mark_backfill_complete(None).await?;
let mut mcp = init_mcp(codex_home.path()).await?;
let request_id = mcp
.send_thread_list_request(codex_app_server_protocol::ThreadListParams {
cursor: None,
limit: Some(10),
sort_key: None,
model_providers: Some(vec!["mock_provider".to_string()]),
source_kinds: None,
archived: None,
cwd: None,
search_term: Some("needle".to_string()),
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadListResponse {
data, next_cursor, ..
} = to_response::<ThreadListResponse>(resp)?;
assert_eq!(next_cursor, None);
let ids: Vec<_> = data.iter().map(|thread| thread.id.as_str()).collect();
assert_eq!(ids, vec![newer_match, older_match]);
Ok(())
}
#[tokio::test]
async fn thread_list_empty_source_kinds_defaults_to_interactive_only() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1335,6 +1417,7 @@ async fn thread_list_invalid_cursor_returns_error() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})
.await?;
let error: JSONRPCError = timeout(
@@ -289,6 +289,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
})
.await?;
let list_resp: JSONRPCResponse = timeout(
@@ -146,6 +146,34 @@ model_reasoning_effort = "high"
Ok(())
}
#[tokio::test]
async fn thread_start_accepts_metrics_service_name() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams {
service_name: Some("my_app_server_client".to_string()),
..Default::default()
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(req_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(resp)?;
assert!(!thread.id.is_empty(), "thread id should not be empty");
Ok(())
}
#[tokio::test]
async fn thread_start_ephemeral_remains_pathless() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
+2
View File
@@ -7,6 +7,7 @@ use std::path::PathBuf;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::exec_env::create_env;
use codex_core::landlock::spawn_command_under_linux_sandbox;
#[cfg(target_os = "macos")]
@@ -223,6 +224,7 @@ async fn run_command_under_sandbox(
None,
None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?,
+2
View File
@@ -50,6 +50,7 @@ codex-utils-pty = { workspace = true }
codex-utils-readiness = { workspace = true }
codex-secrets = { workspace = true }
codex-utils-string = { workspace = true }
codex-utils-stream-parser = { workspace = true }
codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
csv = { workspace = true }
dirs = { workspace = true }
@@ -148,6 +149,7 @@ codex-arg0 = { workspace = true }
codex-otel = { workspace = true, features = [
"disable-default-metrics-exporter",
] }
codex-test-macros = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
core_test_support = { workspace = true }
ctor = { workspace = true }
+1 -7
View File
@@ -41,12 +41,6 @@
"minimum": 1.0,
"type": "integer"
},
"max_spawn_depth": {
"description": "Maximum depth for thread-spawned subagents.",
"format": "uint",
"minimum": 1.0,
"type": "integer"
},
"max_threads": {
"description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.",
"format": "uint",
@@ -2058,7 +2052,7 @@
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Directory where Codex stores the SQLite state DB. Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses a temp dir under WorkspaceWrite sandboxing and `$CODEX_HOME` for other modes."
"description": "Directory where Codex stores the SQLite state DB. Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`."
},
"suppress_unstable_features_warning": {
"description": "Suppress warnings about unstable (under development) features.",
+1 -1
View File
@@ -83,7 +83,7 @@ impl AgentControl {
let new_thread = match session_source {
Some(session_source) => {
state
.spawn_new_thread_with_source(config, self.clone(), session_source, false)
.spawn_new_thread_with_source(config, self.clone(), session_source, false, None)
.await?
}
None => state.spawn_new_thread(config, self.clone()).await?,
-5
View File
@@ -1,4 +1,3 @@
use crate::config::DEFAULT_AGENT_MAX_SPAWN_DEPTH;
use crate::error::CodexErr;
use crate::error::Result;
use codex_protocol::ThreadId;
@@ -43,10 +42,6 @@ pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 {
session_depth(session_source).saturating_add(1)
}
pub(crate) fn max_thread_spawn_depth(max_depth: Option<usize>) -> i32 {
let max_depth = max_depth.or(DEFAULT_AGENT_MAX_SPAWN_DEPTH).unwrap_or(1);
i32::try_from(max_depth).unwrap_or(i32::MAX)
}
pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool {
depth > max_depth
}
-1
View File
@@ -6,6 +6,5 @@ pub(crate) mod status;
pub(crate) use codex_protocol::protocol::AgentStatus;
pub(crate) use control::AgentControl;
pub(crate) use guards::exceeds_thread_spawn_depth_limit;
pub(crate) use guards::max_thread_spawn_depth;
pub(crate) use guards::next_thread_spawn_depth;
pub(crate) use status::agent_status_from_event;
+298 -107
View File
@@ -42,6 +42,8 @@ use crate::stream_events_utils::HandleOutputCtx;
use crate::stream_events_utils::handle_non_tool_response_item;
use crate::stream_events_utils::handle_output_item_done;
use crate::stream_events_utils::last_assistant_message_from_item;
use crate::stream_events_utils::raw_assistant_output_text_from_item;
use crate::stream_events_utils::record_completed_response_item;
use crate::terminal;
use crate::truncate::TruncationPolicy;
use crate::turn_metadata::TurnMetadataState;
@@ -56,6 +58,7 @@ use codex_hooks::HookResult;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::normalize_host;
use codex_protocol::ThreadId;
use codex_protocol::approvals::ExecPolicyAmendment;
@@ -92,6 +95,11 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
use codex_protocol::skill_approval::SkillApprovalResponse;
use codex_rmcp_client::ElicitationResponse;
use codex_rmcp_client::OAuthCredentialsStoreMode;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
use codex_utils_stream_parser::ProposedPlanSegment;
use codex_utils_stream_parser::extract_proposed_plan_text;
use codex_utils_stream_parser::strip_citations;
use futures::future::BoxFuture;
use futures::prelude::*;
use futures::stream::FuturesOrdered;
@@ -174,9 +182,6 @@ use crate::mentions::collect_explicit_app_ids;
use crate::mentions::collect_tool_mentions_from_messages;
use crate::network_policy_decision::execpolicy_network_rule_amendment;
use crate::project_doc::get_user_instructions;
use crate::proposed_plan_parser::ProposedPlanParser;
use crate::proposed_plan_parser::ProposedPlanSegment;
use crate::proposed_plan_parser::extract_proposed_plan_text;
use crate::protocol::AgentMessageContentDeltaEvent;
use crate::protocol::AgentReasoningSectionBreakEvent;
use crate::protocol::ApplyPatchApprovalRequestEvent;
@@ -315,6 +320,7 @@ impl Codex {
agent_control: AgentControl,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@@ -421,6 +427,7 @@ impl Codex {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name,
session_source,
dynamic_tools,
persist_extended_history,
@@ -772,6 +779,8 @@ pub(crate) struct SessionConfiguration {
// TODO(pakrym): Remove config from here
original_config_do_not_use: Arc<Config>,
/// Optional service name tag for session metrics.
metrics_service_name: Option<String>,
/// Source of the session (cli, vscode, exec, mcp, ...)
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
@@ -869,6 +878,7 @@ impl Session {
network_policy_decider: Option<Arc<dyn codex_network_proxy::NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn codex_network_proxy::BlockedRequestObserver>>,
managed_network_requirements_enabled: bool,
audit_metadata: NetworkProxyAuditMetadata,
) -> anyhow::Result<(StartedNetworkProxy, SessionNetworkProxyRuntime)> {
let network_proxy = spec
.start_proxy(
@@ -876,6 +886,7 @@ impl Session {
network_policy_decider,
blocked_request_observer,
managed_network_requirements_enabled,
audit_metadata,
)
.await
.map_err(|err| anyhow::anyhow!("failed to start managed network proxy: {err}"))?;
@@ -1190,18 +1201,37 @@ impl Session {
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let otel_manager = OtelManager::new(
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let originator = crate::default_client::originator().value;
let terminal_type = terminal::user_agent();
let session_model = session_configuration.collaboration_mode.model().to_string();
let mut otel_manager = OtelManager::new(
conversation_id,
session_configuration.collaboration_mode.model(),
session_configuration.collaboration_mode.model(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
session_model.as_str(),
session_model.as_str(),
account_id.clone(),
account_email.clone(),
auth_mode,
crate::default_client::originator().value,
originator.clone(),
config.otel.log_user_prompt,
terminal::user_agent(),
terminal_type.clone(),
session_configuration.session_source.clone(),
);
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
otel_manager = otel_manager.with_metrics_service_name(service_name);
}
let network_proxy_audit_metadata = NetworkProxyAuditMetadata {
conversation_id: Some(conversation_id.to_string()),
app_version: Some(env!("CARGO_PKG_VERSION").to_string()),
user_account_id: account_id,
auth_mode: auth_mode.map(|mode| mode.to_string()),
originator: Some(originator),
user_email: account_email,
terminal_type: Some(terminal_type),
model: Some(session_model.clone()),
slug: Some(session_model),
};
config.features.emit_metrics(&otel_manager);
otel_manager.counter(
"codex.thread.started",
@@ -1308,6 +1338,7 @@ impl Session {
network_policy_decider.as_ref().map(Arc::clone),
blocked_request_observer.as_ref().map(Arc::clone),
managed_network_requirements_enabled,
network_proxy_audit_metadata,
)
.await?;
(Some(network_proxy), Some(session_network_proxy))
@@ -5649,39 +5680,9 @@ struct ProposedPlanItemState {
completed: bool,
}
/// Per-item plan parsers so we can buffer text while detecting `<proposed_plan>`
/// tags without ever mixing buffered lines across item ids.
struct PlanParsers {
assistant: HashMap<String, ProposedPlanParser>,
}
impl PlanParsers {
fn new() -> Self {
Self {
assistant: HashMap::new(),
}
}
fn assistant_parser_mut(&mut self, item_id: &str) -> &mut ProposedPlanParser {
self.assistant
.entry(item_id.to_string())
.or_insert_with(ProposedPlanParser::new)
}
fn take_assistant_parser(&mut self, item_id: &str) -> Option<ProposedPlanParser> {
self.assistant.remove(item_id)
}
fn drain_assistant_parsers(&mut self) -> Vec<(String, ProposedPlanParser)> {
self.assistant.drain().collect()
}
}
/// Aggregated state used only while streaming a plan-mode response.
/// Includes per-item parsers, deferred agent message bookkeeping, and the plan item lifecycle.
struct PlanModeStreamState {
/// Per-item parsers for assistant streams in plan mode.
plan_parsers: PlanParsers,
/// Agent message items started by the model but deferred until we see non-plan text.
pending_agent_message_items: HashMap<String, TurnItem>,
/// Agent message items whose start notification has been emitted.
@@ -5695,7 +5696,6 @@ struct PlanModeStreamState {
impl PlanModeStreamState {
fn new(turn_id: &str) -> Self {
Self {
plan_parsers: PlanParsers::new(),
pending_agent_message_items: HashMap::new(),
started_agent_message_items: HashSet::new(),
leading_whitespace_by_item: HashMap::new(),
@@ -5704,6 +5704,56 @@ impl PlanModeStreamState {
}
}
#[derive(Debug, Default)]
struct AssistantMessageStreamParsers {
plan_mode: bool,
parsers_by_item: HashMap<String, AssistantTextStreamParser>,
}
type ParsedAssistantTextDelta = AssistantTextChunk;
impl AssistantMessageStreamParsers {
fn new(plan_mode: bool) -> Self {
Self {
plan_mode,
parsers_by_item: HashMap::new(),
}
}
fn parser_mut(&mut self, item_id: &str) -> &mut AssistantTextStreamParser {
let plan_mode = self.plan_mode;
self.parsers_by_item
.entry(item_id.to_string())
.or_insert_with(|| AssistantTextStreamParser::new(plan_mode))
}
fn seed_item_text(&mut self, item_id: &str, text: &str) -> ParsedAssistantTextDelta {
if text.is_empty() {
return ParsedAssistantTextDelta::default();
}
self.parser_mut(item_id).push_str(text)
}
fn parse_delta(&mut self, item_id: &str, delta: &str) -> ParsedAssistantTextDelta {
self.parser_mut(item_id).push_str(delta)
}
fn finish_item(&mut self, item_id: &str) -> ParsedAssistantTextDelta {
let Some(mut parser) = self.parsers_by_item.remove(item_id) else {
return ParsedAssistantTextDelta::default();
};
parser.finish()
}
fn drain_finished(&mut self) -> Vec<(String, ParsedAssistantTextDelta)> {
let parsers_by_item = std::mem::take(&mut self.parsers_by_item);
parsers_by_item
.into_iter()
.map(|(item_id, mut parser)| (item_id, parser.finish()))
.collect()
}
}
impl ProposedPlanItemState {
fn new(turn_id: &str) -> Self {
Self {
@@ -5906,35 +5956,68 @@ async fn handle_plan_segments(
}
}
/// Flush any buffered proposed-plan segments when a specific assistant message ends.
async fn flush_proposed_plan_segments_for_item(
async fn emit_streamed_assistant_text_delta(
sess: &Session,
turn_context: &TurnContext,
state: &mut PlanModeStreamState,
plan_mode_state: Option<&mut PlanModeStreamState>,
item_id: &str,
parsed: ParsedAssistantTextDelta,
) {
let Some(mut parser) = state.plan_parsers.take_assistant_parser(item_id) else {
return;
};
let segments = parser.finish();
if segments.is_empty() {
if parsed.is_empty() {
return;
}
handle_plan_segments(sess, turn_context, state, item_id, segments).await;
if !parsed.citations.is_empty() {
// Citation extraction is intentionally local for now; we strip citations from display text
// but do not yet surface them in protocol events.
let _citations = parsed.citations;
}
if let Some(state) = plan_mode_state {
if !parsed.plan_segments.is_empty() {
handle_plan_segments(sess, turn_context, state, item_id, parsed.plan_segments).await;
}
return;
}
if parsed.visible_text.is_empty() {
return;
}
let event = AgentMessageContentDeltaEvent {
thread_id: sess.conversation_id.to_string(),
turn_id: turn_context.sub_id.clone(),
item_id: item_id.to_string(),
delta: parsed.visible_text,
};
sess.send_event(turn_context, EventMsg::AgentMessageContentDelta(event))
.await;
}
/// Flush any remaining assistant plan parsers when the response completes.
async fn flush_proposed_plan_segments_all(
/// Flush buffered assistant text parser state when an assistant message item ends.
async fn flush_assistant_text_segments_for_item(
sess: &Session,
turn_context: &TurnContext,
state: &mut PlanModeStreamState,
plan_mode_state: Option<&mut PlanModeStreamState>,
parsers: &mut AssistantMessageStreamParsers,
item_id: &str,
) {
for (item_id, mut parser) in state.plan_parsers.drain_assistant_parsers() {
let segments = parser.finish();
if segments.is_empty() {
continue;
}
handle_plan_segments(sess, turn_context, state, &item_id, segments).await;
let parsed = parsers.finish_item(item_id);
emit_streamed_assistant_text_delta(sess, turn_context, plan_mode_state, item_id, parsed).await;
}
/// Flush any remaining buffered assistant text parser state at response completion.
async fn flush_assistant_text_segments_all(
sess: &Session,
turn_context: &TurnContext,
mut plan_mode_state: Option<&mut PlanModeStreamState>,
parsers: &mut AssistantMessageStreamParsers,
) {
for (item_id, parsed) in parsers.drain_finished() {
emit_streamed_assistant_text_delta(
sess,
turn_context,
plan_mode_state.as_deref_mut(),
&item_id,
parsed,
)
.await;
}
}
@@ -5955,6 +6038,7 @@ async fn maybe_complete_plan_item_from_message(
}
}
if let Some(plan_text) = extract_proposed_plan_text(&text) {
let (plan_text, _citations) = strip_citations(&plan_text);
if !state.plan_item_state.started {
state.plan_item_state.start(sess, turn_context).await;
}
@@ -6043,7 +6127,7 @@ async fn handle_assistant_item_done_in_plan_mode(
{
maybe_complete_plan_item_from_message(sess, turn_context, state, item).await;
if let Some(turn_item) = handle_non_tool_response_item(item, true).await {
if let Some(turn_item) = handle_non_tool_response_item(item, true) {
emit_turn_item_in_plan_mode(
sess,
turn_context,
@@ -6054,8 +6138,7 @@ async fn handle_assistant_item_done_in_plan_mode(
.await;
}
sess.record_conversation_items(turn_context, std::slice::from_ref(item))
.await;
record_completed_response_item(sess, turn_context, item).await;
if let Some(agent_message) = last_assistant_message_from_item(item, true) {
*last_agent_message = Some(agent_message);
}
@@ -6143,6 +6226,7 @@ async fn try_run_sampling_request(
let mut active_item: Option<TurnItem> = None;
let mut should_emit_turn_diff = false;
let plan_mode = turn_context.collaboration_mode.mode == ModeKind::Plan;
let mut assistant_message_stream_parsers = AssistantMessageStreamParsers::new(plan_mode);
let mut plan_mode_state = plan_mode.then(|| PlanModeStreamState::new(&turn_context.sub_id));
let receiving_span = trace_span!("receiving_stream");
let outcome: CodexResult<SamplingRequestResult> = loop {
@@ -6182,20 +6266,21 @@ async fn try_run_sampling_request(
ResponseEvent::Created => {}
ResponseEvent::OutputItemDone(item) => {
let previously_active_item = active_item.take();
if let Some(state) = plan_mode_state.as_mut() {
if let Some(previous) = previously_active_item.as_ref() {
let item_id = previous.id();
if matches!(previous, TurnItem::AgentMessage(_)) {
flush_proposed_plan_segments_for_item(
&sess,
&turn_context,
state,
&item_id,
)
.await;
}
}
if handle_assistant_item_done_in_plan_mode(
if let Some(previous) = previously_active_item.as_ref()
&& matches!(previous, TurnItem::AgentMessage(_))
{
let item_id = previous.id();
flush_assistant_text_segments_for_item(
&sess,
&turn_context,
plan_mode_state.as_mut(),
&mut assistant_message_stream_parsers,
&item_id,
)
.await;
}
if let Some(state) = plan_mode_state.as_mut()
&& handle_assistant_item_done_in_plan_mode(
&sess,
&turn_context,
&item,
@@ -6204,9 +6289,8 @@ async fn try_run_sampling_request(
&mut last_agent_message,
)
.await
{
continue;
}
{
continue;
}
let mut ctx = HandleOutputCtx {
@@ -6228,7 +6312,29 @@ async fn try_run_sampling_request(
needs_follow_up |= output_result.needs_follow_up;
}
ResponseEvent::OutputItemAdded(item) => {
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await {
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode) {
let mut turn_item = turn_item;
let mut seeded_parsed: Option<ParsedAssistantTextDelta> = None;
let mut seeded_item_id: Option<String> = None;
if matches!(turn_item, TurnItem::AgentMessage(_))
&& let Some(raw_text) = raw_assistant_output_text_from_item(&item)
{
let item_id = turn_item.id();
let mut seeded =
assistant_message_stream_parsers.seed_item_text(&item_id, &raw_text);
if let TurnItem::AgentMessage(agent_message) = &mut turn_item {
agent_message.content =
vec![codex_protocol::items::AgentMessageContent::Text {
text: if plan_mode {
String::new()
} else {
std::mem::take(&mut seeded.visible_text)
},
}];
}
seeded_parsed = plan_mode.then_some(seeded);
seeded_item_id = Some(item_id);
}
if let Some(state) = plan_mode_state.as_mut()
&& matches!(turn_item, TurnItem::AgentMessage(_))
{
@@ -6239,6 +6345,20 @@ async fn try_run_sampling_request(
} else {
sess.emit_turn_item_started(&turn_context, &turn_item).await;
}
if let (Some(state), Some(item_id), Some(parsed)) = (
plan_mode_state.as_mut(),
seeded_item_id.as_deref(),
seeded_parsed,
) {
emit_streamed_assistant_text_delta(
&sess,
&turn_context,
Some(state),
item_id,
parsed,
)
.await;
}
active_item = Some(turn_item);
}
}
@@ -6268,9 +6388,13 @@ async fn try_run_sampling_request(
token_usage,
can_append: _,
} => {
if let Some(state) = plan_mode_state.as_mut() {
flush_proposed_plan_segments_all(&sess, &turn_context, state).await;
}
flush_assistant_text_segments_all(
&sess,
&turn_context,
plan_mode_state.as_mut(),
&mut assistant_message_stream_parsers,
)
.await;
sess.update_token_usage_info(&turn_context, token_usage.as_ref())
.await;
should_emit_turn_diff = true;
@@ -6287,14 +6411,16 @@ async fn try_run_sampling_request(
// UI will show a selection popup from the final ReviewOutput.
if let Some(active) = active_item.as_ref() {
let item_id = active.id();
if let Some(state) = plan_mode_state.as_mut()
&& matches!(active, TurnItem::AgentMessage(_))
{
let segments = state
.plan_parsers
.assistant_parser_mut(&item_id)
.parse(&delta);
handle_plan_segments(&sess, &turn_context, state, &item_id, segments).await;
if matches!(active, TurnItem::AgentMessage(_)) {
let parsed = assistant_message_stream_parsers.parse_delta(&item_id, &delta);
emit_streamed_assistant_text_delta(
&sess,
&turn_context,
plan_mode_state.as_mut(),
&item_id,
parsed,
)
.await;
} else {
let event = AgentMessageContentDeltaEvent {
thread_id: sess.conversation_id.to_string(),
@@ -6360,6 +6486,14 @@ async fn try_run_sampling_request(
}
};
flush_assistant_text_segments_all(
&sess,
&turn_context,
plan_mode_state.as_mut(),
&mut assistant_message_stream_parsers,
)
.await;
drain_in_flight(&mut in_flight, sess.clone(), turn_context.clone()).await?;
if should_emit_turn_diff {
@@ -6377,23 +6511,12 @@ async fn try_run_sampling_request(
}
pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option<String> {
responses.iter().rev().find_map(|item| {
if let ResponseItem::Message { role, content, .. } = item {
if role == "assistant" {
content.iter().rev().find_map(|ci| {
if let ContentItem::OutputText { text } = ci {
Some(text.clone())
} else {
None
}
})
} else {
None
}
} else {
None
for item in responses.iter().rev() {
if let Some(message) = last_assistant_message_from_item(item, false) {
return Some(message);
}
})
}
None
}
use crate::memories::prompts::build_memory_tool_developer_instructions;
@@ -6517,6 +6640,68 @@ mod tests {
}
}
#[test]
fn assistant_message_stream_parsers_can_be_seeded_from_output_item_added_text() {
let mut parsers = AssistantMessageStreamParsers::new(false);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-citation>doc");
let parsed = parsers.parse_delta(item_id, "1</oai-mem-citation> world");
let tail = parsers.finish_item(item_id);
assert_eq!(seeded.visible_text, "hello ");
assert_eq!(seeded.citations, Vec::<String>::new());
assert_eq!(parsed.visible_text, " world");
assert_eq!(parsed.citations, vec!["doc1".to_string()]);
assert_eq!(tail.visible_text, "");
assert_eq!(tail.citations, Vec::<String>::new());
}
#[test]
fn assistant_message_stream_parsers_seed_buffered_prefix_stays_out_of_finish_tail() {
let mut parsers = AssistantMessageStreamParsers::new(false);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "hello <oai-mem-");
let parsed = parsers.parse_delta(item_id, "citation>doc</oai-mem-citation> world");
let tail = parsers.finish_item(item_id);
assert_eq!(seeded.visible_text, "hello ");
assert_eq!(seeded.citations, Vec::<String>::new());
assert_eq!(parsed.visible_text, " world");
assert_eq!(parsed.citations, vec!["doc".to_string()]);
assert_eq!(tail.visible_text, "");
assert_eq!(tail.citations, Vec::<String>::new());
}
#[test]
fn assistant_message_stream_parsers_seed_plan_parser_across_added_and_delta_boundaries() {
let mut parsers = AssistantMessageStreamParsers::new(true);
let item_id = "msg-1";
let seeded = parsers.seed_item_text(item_id, "Intro\n<proposed");
let parsed = parsers.parse_delta(item_id, "_plan>\n- step\n</proposed_plan>\nOutro");
let tail = parsers.finish_item(item_id);
assert_eq!(seeded.visible_text, "Intro\n");
assert_eq!(
seeded.plan_segments,
vec![ProposedPlanSegment::Normal("Intro\n".to_string())]
);
assert_eq!(parsed.visible_text, "Outro");
assert_eq!(
parsed.plan_segments,
vec![
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
ProposedPlanSegment::Normal("Outro".to_string()),
]
);
assert_eq!(tail.visible_text, "");
assert!(tail.plan_segments.is_empty());
}
fn make_mcp_tool(
server_name: &str,
tool_name: &str,
@@ -7680,6 +7865,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -7771,6 +7957,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8081,6 +8268,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8132,6 +8320,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8211,6 +8400,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8368,6 +8558,7 @@ mod tests {
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
session_source: SessionSource::Exec,
dynamic_tools,
persist_extended_history: false,
+1
View File
@@ -59,6 +59,7 @@ pub(crate) async fn run_codex_thread_interactive(
parent_session.services.agent_control.clone(),
Vec::new(),
false,
None,
)
.await?;
let codex = Arc::new(codex);
+20 -44
View File
@@ -100,6 +100,7 @@ pub mod types;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
pub use codex_network_proxy::NetworkProxyAuditMetadata;
pub use network_proxy_spec::NetworkProxySpec;
pub use network_proxy_spec::StartedNetworkProxy;
@@ -115,22 +116,11 @@ pub use codex_git::GhostSnapshotConfig;
/// the context window.
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_SPAWN_DEPTH: Option<usize> = Some(2);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
pub const CONFIG_TOML_FILE: &str = "config.toml";
fn default_sqlite_home(sandbox_policy: &SandboxPolicy, codex_home: &Path) -> PathBuf {
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
let mut path = std::env::temp_dir();
path.push("codex-sqlite");
path
} else {
codex_home.to_path_buf()
}
}
fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
let raw = std::env::var(codex_state::SQLITE_HOME_ENV).ok()?;
let trimmed = raw.trim();
@@ -357,8 +347,6 @@ pub struct Config {
/// Maximum number of agent threads that can be open concurrently.
pub agent_max_threads: Option<usize>,
/// Maximum depth for thread-spawned subagents.
pub agent_max_spawn_depth: Option<usize>,
/// Maximum runtime in seconds for agent job workers before they are failed.
pub agent_job_max_runtime_seconds: Option<u64>,
@@ -1149,8 +1137,7 @@ pub struct ConfigToml {
pub history: Option<History>,
/// Directory where Codex stores the SQLite state DB.
/// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses a temp dir
/// under WorkspaceWrite sandboxing and `$CODEX_HOME` for other modes.
/// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`.
pub sqlite_home: Option<AbsolutePathBuf>,
/// Directory where Codex writes log files, for example `codex-tui.log`.
@@ -1340,9 +1327,6 @@ pub struct AgentsToml {
/// When unset, no limit is enforced.
#[schemars(range(min = 1))]
pub max_threads: Option<usize>,
/// Maximum depth for thread-spawned subagents.
#[schemars(range(min = 1))]
pub max_spawn_depth: Option<usize>,
/// Maximum nesting depth allowed for spawned agent threads.
/// Root sessions start at depth 0.
#[schemars(range(min = 1))]
@@ -1865,25 +1849,6 @@ impl Config {
})
.transpose()?
.unwrap_or_default();
let agent_max_spawn_depth = cfg
.agents
.as_ref()
.and_then(|agents| agents.max_spawn_depth)
.or(DEFAULT_AGENT_MAX_SPAWN_DEPTH);
if agent_max_spawn_depth == Some(0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_spawn_depth must be at least 1",
));
}
if let Some(max_spawn_depth) = agent_max_spawn_depth
&& max_spawn_depth > i32::MAX as usize
{
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"agents.max_spawn_depth must fit within a 32-bit signed integer",
));
}
let agent_job_max_runtime_seconds = cfg
.agents
.as_ref()
@@ -2032,7 +1997,7 @@ impl Config {
.as_ref()
.map(AbsolutePathBuf::to_path_buf)
.or_else(|| resolve_sqlite_home_env(&resolved_cwd))
.unwrap_or_else(|| default_sqlite_home(&sandbox_policy, &codex_home));
.unwrap_or_else(|| codex_home.to_path_buf());
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
@@ -2149,7 +2114,6 @@ impl Config {
agent_max_depth,
agent_roles,
memories: cfg.memories.unwrap_or_default().into(),
agent_max_spawn_depth,
agent_job_max_runtime_seconds,
codex_home,
sqlite_home,
@@ -2983,6 +2947,23 @@ trust_level = "trusted"
Ok(())
}
#[test]
fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::WorkspaceWrite),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert_eq!(config.sqlite_home, codex_home.path().to_path_buf());
Ok(())
}
#[test]
fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -4487,7 +4468,6 @@ model = "gpt-5.1-codex"
let cfg = ConfigToml {
agents: Some(AgentsToml {
max_threads: None,
max_spawn_depth: None,
max_depth: None,
job_max_runtime_seconds: None,
roles: BTreeMap::from([(
@@ -4763,7 +4743,6 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
@@ -4890,7 +4869,6 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
@@ -5015,7 +4993,6 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
@@ -5126,7 +5103,6 @@ model_verbosity = "high"
agent_max_depth: DEFAULT_AGENT_MAX_DEPTH,
agent_roles: BTreeMap::new(),
memories: MemoriesConfig::default(),
agent_max_spawn_depth: DEFAULT_AGENT_MAX_SPAWN_DEPTH,
agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS,
codex_home: fixture.codex_home(),
sqlite_home: fixture.codex_home(),
+44 -6
View File
@@ -6,6 +6,7 @@ use codex_network_proxy::ConfigState;
use codex_network_proxy::NetworkDecision;
use codex_network_proxy::NetworkPolicyDecider;
use codex_network_proxy::NetworkProxy;
use codex_network_proxy::NetworkProxyAuditMetadata;
use codex_network_proxy::NetworkProxyConfig;
use codex_network_proxy::NetworkProxyConstraints;
use codex_network_proxy::NetworkProxyHandle;
@@ -106,13 +107,9 @@ impl NetworkProxySpec {
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
enable_network_approval_flow: bool,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<StartedNetworkProxy> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
let state = NetworkProxyState::with_reloader(state, reloader);
let state = self.build_state_with_audit_metadata(audit_metadata)?;
let mut builder = NetworkProxy::builder().state(Arc::new(state));
if enable_network_approval_flow
&& matches!(
@@ -142,6 +139,22 @@ impl NetworkProxySpec {
Ok(StartedNetworkProxy::new(proxy, handle))
}
fn build_state_with_audit_metadata(
&self,
audit_metadata: NetworkProxyAuditMetadata,
) -> std::io::Result<NetworkProxyState> {
let state =
build_config_state(self.config.clone(), self.constraints.clone()).map_err(|err| {
std::io::Error::other(format!("failed to build network proxy state: {err}"))
})?;
let reloader = Arc::new(StaticNetworkProxyReloader::new(state.clone()));
Ok(NetworkProxyState::with_reloader_and_audit_metadata(
state,
reloader,
audit_metadata,
))
}
fn apply_requirements(
mut config: NetworkProxyConfig,
requirements: &NetworkConstraints,
@@ -205,3 +218,28 @@ impl NetworkProxySpec {
(config, constraints)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn build_state_with_audit_metadata_threads_metadata_to_state() {
let spec = NetworkProxySpec {
config: NetworkProxyConfig::default(),
constraints: NetworkProxyConstraints::default(),
};
let metadata = NetworkProxyAuditMetadata {
conversation_id: Some("conversation-1".to_string()),
app_version: Some("1.2.3".to_string()),
user_account_id: Some("acct-1".to_string()),
..NetworkProxyAuditMetadata::default()
};
let state = spec
.build_state_with_audit_metadata(metadata.clone())
.expect("state should build");
assert_eq!(state.audit_metadata(), &metadata);
}
}
+920
View File
@@ -0,0 +1,920 @@
use serde_json::Value as JsonValue;
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use toml::Value as TomlValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigDetectOptions {
pub include_home: bool,
pub cwds: Option<Vec<PathBuf>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalAgentConfigMigrationItemType {
Config,
Skills,
AgentsMd,
McpServerConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentConfigMigrationItem {
pub item_type: ExternalAgentConfigMigrationItemType,
pub description: String,
pub cwd: Option<PathBuf>,
}
#[derive(Clone)]
pub struct ExternalAgentConfigService {
codex_home: PathBuf,
claude_home: PathBuf,
}
impl ExternalAgentConfigService {
pub fn new(codex_home: PathBuf) -> Self {
let claude_home = default_claude_home();
Self {
codex_home,
claude_home,
}
}
#[cfg(test)]
fn new_for_test(codex_home: PathBuf, claude_home: PathBuf) -> Self {
Self {
codex_home,
claude_home,
}
}
pub fn detect(
&self,
params: ExternalAgentConfigDetectOptions,
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
let mut items = Vec::new();
if params.include_home {
self.detect_migrations(None, &mut items)?;
}
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
let Some(repo_root) = find_repo_root(Some(cwd))? else {
continue;
};
self.detect_migrations(Some(&repo_root), &mut items)?;
}
Ok(items)
}
pub fn import(&self, migration_items: Vec<ExternalAgentConfigMigrationItem>) -> io::Result<()> {
for migration_item in migration_items {
match migration_item.item_type {
ExternalAgentConfigMigrationItemType::Config => {
self.import_config(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::Skills => {
self.import_skills(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::AgentsMd => {
self.import_agents_md(migration_item.cwd.as_deref())?
}
ExternalAgentConfigMigrationItemType::McpServerConfig => {}
}
}
Ok(())
}
fn detect_migrations(
&self,
repo_root: Option<&Path>,
items: &mut Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<()> {
let cwd = repo_root.map(Path::to_path_buf);
let source_settings = repo_root.map_or_else(
|| self.claude_home.join("settings.json"),
|repo_root| repo_root.join(".claude").join("settings.json"),
);
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
);
if source_settings.is_file() {
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if !is_empty_toml_table(&migrated) {
let mut should_include = true;
if target_config.exists() {
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw).map_err(|err| {
invalid_data_error(format!("invalid existing config.toml: {err}"))
})?
};
should_include = merge_missing_toml_values(&mut existing, &migrated)?;
}
if should_include {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}.",
source_settings.display(),
target_config.display()
),
cwd: cwd.clone(),
});
}
}
}
let source_skills = repo_root.map_or_else(
|| self.claude_home.join("skills"),
|repo_root| repo_root.join(".claude").join("skills"),
);
let target_skills = repo_root.map_or_else(
|| self.home_target_skills_dir(),
|repo_root| repo_root.join(".agents").join("skills"),
);
let source_skill_names = collect_subdirectory_names(&source_skills)?;
let target_skill_names = collect_subdirectory_names(&target_skills)?;
if source_skill_names
.iter()
.any(|skill_name| !target_skill_names.contains(skill_name))
{
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}.",
source_skills.display(),
target_skills.display()
),
cwd: cwd.clone(),
});
}
let source_agents_md = repo_root.map_or_else(
|| self.claude_home.join("CLAUDE.md"),
|repo_root| repo_root.join("CLAUDE.md"),
);
let target_agents_md = repo_root.map_or_else(
|| self.codex_home.join("AGENTS.md"),
|repo_root| repo_root.join("AGENTS.md"),
);
if source_agents_md.is_file() && is_missing_or_empty_text_file(&target_agents_md)? {
items.push(ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
source_agents_md.display(),
target_agents_md.display()
),
cwd,
});
}
Ok(())
}
fn home_target_skills_dir(&self) -> PathBuf {
self.codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"))
}
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("settings.json"),
repo_root.join(".codex").join("config.toml"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("settings.json"),
self.codex_home.join("config.toml"),
)
};
if !source_settings.is_file() {
return Ok(());
}
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(());
}
let Some(target_parent) = target_config.parent() else {
return Err(invalid_data_error("config target path has no parent"));
};
fs::create_dir_all(target_parent)?;
if !target_config.exists() {
write_toml_file(&target_config, &migrated)?;
return Ok(());
}
let existing_raw = fs::read_to_string(&target_config)?;
let mut existing = if existing_raw.trim().is_empty() {
TomlValue::Table(Default::default())
} else {
toml::from_str::<TomlValue>(&existing_raw)
.map_err(|err| invalid_data_error(format!("invalid existing config.toml: {err}")))?
};
let changed = merge_missing_toml_values(&mut existing, &migrated)?;
if !changed {
return Ok(());
}
write_toml_file(&target_config, &existing)?;
Ok(())
}
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
(
repo_root.join(".claude").join("skills"),
repo_root.join(".agents").join("skills"),
)
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("skills"),
self.home_target_skills_dir(),
)
};
if !source_skills.is_dir() {
return Ok(());
}
fs::create_dir_all(&target_skills)?;
for entry in fs::read_dir(&source_skills)? {
let entry = entry?;
let file_type = entry.file_type()?;
if !file_type.is_dir() {
continue;
}
let target = target_skills.join(entry.file_name());
if target.exists() {
continue;
}
copy_dir_recursive(&entry.path(), &target)?;
}
Ok(())
}
fn import_agents_md(&self, cwd: Option<&Path>) -> io::Result<()> {
let (source_agents_md, target_agents_md) = if let Some(repo_root) = find_repo_root(cwd)? {
(repo_root.join("CLAUDE.md"), repo_root.join("AGENTS.md"))
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
return Ok(());
} else {
(
self.claude_home.join("CLAUDE.md"),
self.codex_home.join("AGENTS.md"),
)
};
if !source_agents_md.is_file() || !is_missing_or_empty_text_file(&target_agents_md)? {
return Ok(());
}
let Some(target_parent) = target_agents_md.parent() else {
return Err(invalid_data_error("AGENTS.md target path has no parent"));
};
fs::create_dir_all(target_parent)?;
rewrite_and_copy_text_file(&source_agents_md, &target_agents_md)
}
}
fn default_claude_home() -> PathBuf {
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
return PathBuf::from(home).join(".claude");
}
PathBuf::from(".claude")
}
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
let Some(cwd) = cwd.filter(|cwd| !cwd.as_os_str().is_empty()) else {
return Ok(None);
};
let mut current = if cwd.is_absolute() {
cwd.to_path_buf()
} else {
std::env::current_dir()?.join(cwd)
};
if !current.exists() {
return Ok(None);
}
if current.is_file() {
let Some(parent) = current.parent() else {
return Ok(None);
};
current = parent.to_path_buf();
}
let fallback = current.clone();
loop {
let git_path = current.join(".git");
if git_path.is_dir() || git_path.is_file() {
return Ok(Some(current));
}
if !current.pop() {
break;
}
}
Ok(Some(fallback))
}
fn collect_subdirectory_names(path: &Path) -> io::Result<HashSet<OsString>> {
let mut names = HashSet::new();
if !path.is_dir() {
return Ok(names);
}
for entry in fs::read_dir(path)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
names.insert(entry.file_name());
}
}
Ok(names)
}
fn is_missing_or_empty_text_file(path: &Path) -> io::Result<bool> {
if !path.exists() {
return Ok(true);
}
if !path.is_file() {
return Ok(false);
}
Ok(fs::read_to_string(path)?.trim().is_empty())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry.file_type()?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
continue;
}
if file_type.is_file() {
if is_skill_md(&source_path) {
rewrite_and_copy_text_file(&source_path, &target_path)?;
} else {
fs::copy(source_path, target_path)?;
}
}
}
Ok(())
}
fn is_skill_md(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("SKILL.md"))
}
fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
let source_contents = fs::read_to_string(source)?;
let rewritten = rewrite_claude_terms(&source_contents);
fs::write(target, rewritten)
}
fn rewrite_claude_terms(content: &str) -> String {
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
for from in [
"claude code",
"claude-code",
"claude_code",
"claudecode",
"claude",
] {
rewritten = replace_case_insensitive_with_boundaries(&rewritten, from, "Codex");
}
rewritten
}
fn replace_case_insensitive_with_boundaries(
input: &str,
needle: &str,
replacement: &str,
) -> String {
let needle_lower = needle.to_ascii_lowercase();
if needle_lower.is_empty() {
return input.to_string();
}
let haystack_lower = input.to_ascii_lowercase();
let bytes = input.as_bytes();
let mut output = String::with_capacity(input.len());
let mut last_emitted = 0usize;
let mut search_start = 0usize;
while let Some(relative_pos) = haystack_lower[search_start..].find(&needle_lower) {
let start = search_start + relative_pos;
let end = start + needle_lower.len();
let boundary_before = start == 0 || !is_word_byte(bytes[start - 1]);
let boundary_after = end == bytes.len() || !is_word_byte(bytes[end]);
if boundary_before && boundary_after {
output.push_str(&input[last_emitted..start]);
output.push_str(replacement);
last_emitted = end;
}
search_start = start + 1;
}
if last_emitted == 0 {
return input.to_string();
}
output.push_str(&input[last_emitted..]);
output
}
fn is_word_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_'
}
fn build_config_from_external(settings: &JsonValue) -> io::Result<TomlValue> {
let Some(settings_obj) = settings.as_object() else {
return Err(invalid_data_error(
"external agent settings root must be an object",
));
};
let mut root = toml::map::Map::new();
if let Some(env) = settings_obj.get("env").and_then(JsonValue::as_object)
&& !env.is_empty()
{
let mut shell_policy = toml::map::Map::new();
shell_policy.insert("inherit".to_string(), TomlValue::String("core".to_string()));
shell_policy.insert(
"set".to_string(),
TomlValue::Table(json_object_to_toml_table(env)?),
);
root.insert(
"shell_environment_policy".to_string(),
TomlValue::Table(shell_policy),
);
}
if let Some(sandbox_enabled) = settings_obj
.get("sandbox")
.and_then(JsonValue::as_object)
.and_then(|sandbox| sandbox.get("enabled"))
.and_then(JsonValue::as_bool)
&& sandbox_enabled
{
root.insert(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
);
}
Ok(TomlValue::Table(root))
}
fn json_object_to_toml_table(
object: &serde_json::Map<String, JsonValue>,
) -> io::Result<toml::map::Map<String, TomlValue>> {
let mut table = toml::map::Map::new();
for (key, value) in object {
table.insert(key.clone(), json_to_toml_value(value)?);
}
Ok(table)
}
fn json_to_toml_value(value: &JsonValue) -> io::Result<TomlValue> {
match value {
JsonValue::Null => Ok(TomlValue::String("null".to_string())),
JsonValue::Bool(v) => Ok(TomlValue::Boolean(*v)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
return Ok(TomlValue::Integer(i));
}
if let Some(f) = n.as_f64() {
return Ok(TomlValue::Float(f));
}
Err(invalid_data_error("unsupported JSON number"))
}
JsonValue::String(v) => Ok(TomlValue::String(v.clone())),
JsonValue::Array(values) => values
.iter()
.map(json_to_toml_value)
.collect::<io::Result<Vec<_>>>()
.map(TomlValue::Array),
JsonValue::Object(map) => json_object_to_toml_table(map).map(TomlValue::Table),
}
}
fn merge_missing_toml_values(existing: &mut TomlValue, incoming: &TomlValue) -> io::Result<bool> {
match (existing, incoming) {
(TomlValue::Table(existing_table), TomlValue::Table(incoming_table)) => {
let mut changed = false;
for (key, incoming_value) in incoming_table {
match existing_table.get_mut(key) {
Some(existing_value) => {
if matches!(
(&*existing_value, incoming_value),
(TomlValue::Table(_), TomlValue::Table(_))
) && merge_missing_toml_values(existing_value, incoming_value)?
{
changed = true;
}
}
None => {
existing_table.insert(key.clone(), incoming_value.clone());
changed = true;
}
}
}
Ok(changed)
}
_ => Err(invalid_data_error(
"expected TOML table while merging migrated config values",
)),
}
}
fn write_toml_file(path: &Path, value: &TomlValue) -> io::Result<()> {
let serialized = toml::to_string_pretty(value)
.map_err(|err| invalid_data_error(format!("failed to serialize config.toml: {err}")))?;
fs::write(path, format!("{serialized}\n"))
}
fn is_empty_toml_table(value: &TomlValue) -> bool {
match value {
TomlValue::Table(table) => table.is_empty(),
TomlValue::String(_)
| TomlValue::Integer(_)
| TomlValue::Float(_)
| TomlValue::Boolean(_)
| TomlValue::Datetime(_)
| TomlValue::Array(_) => false,
}
}
fn invalid_data_error(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn fixture_paths() -> (TempDir, PathBuf, PathBuf) {
let root = TempDir::new().expect("create tempdir");
let claude_home = root.path().join(".claude");
let codex_home = root.path().join(".codex");
(root, claude_home, codex_home)
}
fn service_for_paths(claude_home: PathBuf, codex_home: PathBuf) -> ExternalAgentConfigService {
ExternalAgentConfigService::new_for_test(codex_home, claude_home)
}
#[test]
fn detect_home_lists_config_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::write(claude_home.join("CLAUDE.md"), "claude rules").expect("write claude md");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","env":{"FOO":"bar"}}"#,
)
.expect("write settings");
let items = service_for_paths(claude_home.clone(), codex_home.clone())
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: format!(
"Migrate {} into {}.",
claude_home.join("settings.json").display(),
codex_home.join("config.toml").display()
),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: format!(
"Copy skill folders from {} to {}.",
claude_home.join("skills").display(),
agents_skills.display()
),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
claude_home.join("CLAUDE.md").display(),
codex_home.join("AGENTS.md").display()
),
cwd: None,
},
];
assert_eq!(items, expected);
}
#[test]
fn detect_repo_lists_agents_md_for_each_cwd() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let nested = repo_root.join("nested").join("child");
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(&nested).expect("create nested");
fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source");
let items = service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.detect(ExternalAgentConfigDetectOptions {
include_home: false,
cwds: Some(vec![nested, repo_root.clone()]),
})
.expect("detect");
let expected = vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root.clone()),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: format!(
"Import {} to {}.",
repo_root.join("CLAUDE.md").display(),
repo_root.join("AGENTS.md").display(),
),
cwd: Some(repo_root),
},
];
assert_eq!(items, expected);
}
#[test]
fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create skills");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","permissions":{"ask":["git push"]},"env":{"FOO":"bar"},"sandbox":{"enabled":true,"network":{"allowLocalBinding":true}}}"#,
)
.expect("write settings");
fs::write(
claude_home.join("skills").join("skill-a").join("SKILL.md"),
"Use Claude Code and CLAUDE utilities.",
)
.expect("write skill");
fs::write(claude_home.join("CLAUDE.md"), "Claude code guidance").expect("write agents");
service_for_paths(claude_home, codex_home.clone())
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Skills,
description: String::new(),
cwd: None,
},
])
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"),
"Codex guidance"
);
let parsed_config: TomlValue = toml::from_str(
&fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected_config: TomlValue = toml::from_str(
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "bar"
"#,
)
.expect("parse expected");
assert_eq!(parsed_config, expected_config);
assert_eq!(
fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md"))
.expect("read copied skill"),
"Use Codex and Codex utilities."
);
}
#[test]
fn import_home_skips_empty_config_migration() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::write(
claude_home.join("settings.json"),
r#"{"model":"claude","sandbox":{"enabled":false}}"#,
)
.expect("write settings");
service_for_paths(claude_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
}])
.expect("import");
assert!(!codex_home.join("config.toml").exists());
}
#[test]
fn detect_home_skips_config_when_target_already_has_supported_fields() {
let (_root, claude_home, codex_home) = fixture_paths();
fs::create_dir_all(&claude_home).expect("create claude home");
fs::create_dir_all(&codex_home).expect("create codex home");
fs::write(
claude_home.join("settings.json"),
r#"{"env":{"FOO":"bar"},"sandbox":{"enabled":true}}"#,
)
.expect("write settings");
fs::write(
codex_home.join("config.toml"),
r#"
sandbox_mode = "workspace-write"
[shell_environment_policy]
inherit = "core"
[shell_environment_policy.set]
FOO = "bar"
"#,
)
.expect("write config");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn detect_home_skips_skills_when_all_skill_directories_exist() {
let (_root, claude_home, codex_home) = fixture_paths();
let agents_skills = codex_home
.parent()
.map(|parent| parent.join(".agents").join("skills"))
.unwrap_or_else(|| PathBuf::from(".agents").join("skills"));
fs::create_dir_all(claude_home.join("skills").join("skill-a")).expect("create source");
fs::create_dir_all(agents_skills.join("skill-a")).expect("create target");
let items = service_for_paths(claude_home, codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.expect("detect");
assert_eq!(items, Vec::<ExternalAgentConfigMigrationItem>::new());
}
#[test]
fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo-a");
let repo_with_existing_target = root.path().join("repo-b");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::create_dir_all(repo_with_existing_target.join(".git")).expect("create git");
fs::write(
repo_root.join("CLAUDE.md"),
"Claude code\nclaude\nCLAUDE-CODE\nSee CLAUDE.md\n",
)
.expect("write source");
fs::write(repo_with_existing_target.join("CLAUDE.md"), "new source").expect("write source");
fs::write(
repo_with_existing_target.join("AGENTS.md"),
"keep existing target",
)
.expect("write target");
service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.import(vec![
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
},
ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_with_existing_target.clone()),
},
])
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex\nCodex\nCodex\nSee AGENTS.md\n"
);
assert_eq!(
fs::read_to_string(repo_with_existing_target.join("AGENTS.md"))
.expect("read existing target"),
"keep existing target"
);
}
#[test]
fn import_repo_agents_md_overwrites_empty_targets() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).expect("create git");
fs::write(repo_root.join("CLAUDE.md"), "Claude code guidance").expect("write source");
fs::write(repo_root.join("AGENTS.md"), " \n\t").expect("write empty target");
service_for_paths(root.path().join(".claude"), root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
description: String::new(),
cwd: Some(repo_root.clone()),
}])
.expect("import");
assert_eq!(
fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"),
"Codex guidance"
);
}
}
+1 -2
View File
@@ -34,6 +34,7 @@ pub mod error;
pub mod exec;
pub mod exec_env;
mod exec_policy;
pub mod external_agent_config;
pub mod features;
mod file_watcher;
mod flags;
@@ -55,13 +56,11 @@ mod message_history;
mod model_provider_info;
pub mod path_utils;
pub mod personality_migration;
mod proposed_plan_parser;
mod sandbox_tags;
pub mod sandboxing;
mod session_prefix;
mod shell_detect;
mod stream_events_utils;
mod tagged_block_parser;
pub mod test_support;
mod text_encoding;
pub mod token_data;
+62
View File
@@ -0,0 +1,62 @@
use codex_protocol::ThreadId;
pub fn get_thread_id_from_citations(citations: Vec<String>) -> Vec<ThreadId> {
let mut result = Vec::new();
for citation in citations {
let mut ids_block = None;
for (open, close) in [
("<thread_ids>", "</thread_ids>"),
("<rollout_ids>", "</rollout_ids>"),
] {
if let Some((_, rest)) = citation.split_once(open)
&& let Some((ids, _)) = rest.split_once(close)
{
ids_block = Some(ids);
break;
}
}
if let Some(ids_block) = ids_block {
for id in ids_block
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
if let Ok(thread_id) = ThreadId::try_from(id) {
result.push(thread_id);
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::get_thread_id_from_citations;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
#[test]
fn get_thread_id_from_citations_extracts_thread_ids() {
let first = ThreadId::new();
let second = ThreadId::new();
let citations = vec![format!(
"<memory_citation>\n<citation_entries>\nMEMORY.md:1-2|note=[x]\n</citation_entries>\n<thread_ids>\n{first}\nnot-a-uuid\n{second}\n</thread_ids>\n</memory_citation>"
)];
assert_eq!(get_thread_id_from_citations(citations), vec![first, second]);
}
#[test]
fn get_thread_id_from_citations_supports_legacy_rollout_ids() {
let thread_id = ThreadId::new();
let citations = vec![format!(
"<memory_citation>\n<rollout_ids>\n{thread_id}\n</rollout_ids>\n</memory_citation>"
)];
assert_eq!(get_thread_id_from_citations(citations), vec![thread_id]);
}
}
+1
View File
@@ -4,6 +4,7 @@
//! - Phase 1: select rollouts, extract stage-1 raw memories, persist stage-1 outputs, and enqueue consolidation.
//! - Phase 2: claim a global consolidation lock, materialize consolidation inputs, and dispatch one consolidation agent.
pub(crate) mod citations;
mod phase1;
mod phase2;
pub(crate) mod prompts;
-185
View File
@@ -1,185 +0,0 @@
use crate::tagged_block_parser::TagSpec;
use crate::tagged_block_parser::TaggedLineParser;
use crate::tagged_block_parser::TaggedLineSegment;
const OPEN_TAG: &str = "<proposed_plan>";
const CLOSE_TAG: &str = "</proposed_plan>";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlanTag {
ProposedPlan,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ProposedPlanSegment {
Normal(String),
ProposedPlanStart,
ProposedPlanDelta(String),
ProposedPlanEnd,
}
/// Parser for `<proposed_plan>` blocks emitted in plan mode.
///
/// This is a thin wrapper around the generic line-based tag parser. It maps
/// tag-aware segments into plan-specific segments for downstream consumers.
#[derive(Debug)]
pub(crate) struct ProposedPlanParser {
parser: TaggedLineParser<PlanTag>,
}
impl ProposedPlanParser {
pub(crate) fn new() -> Self {
Self {
parser: TaggedLineParser::new(vec![TagSpec {
open: OPEN_TAG,
close: CLOSE_TAG,
tag: PlanTag::ProposedPlan,
}]),
}
}
pub(crate) fn parse(&mut self, delta: &str) -> Vec<ProposedPlanSegment> {
self.parser
.parse(delta)
.into_iter()
.map(map_plan_segment)
.collect()
}
pub(crate) fn finish(&mut self) -> Vec<ProposedPlanSegment> {
self.parser
.finish()
.into_iter()
.map(map_plan_segment)
.collect()
}
}
fn map_plan_segment(segment: TaggedLineSegment<PlanTag>) -> ProposedPlanSegment {
match segment {
TaggedLineSegment::Normal(text) => ProposedPlanSegment::Normal(text),
TaggedLineSegment::TagStart(PlanTag::ProposedPlan) => {
ProposedPlanSegment::ProposedPlanStart
}
TaggedLineSegment::TagDelta(PlanTag::ProposedPlan, text) => {
ProposedPlanSegment::ProposedPlanDelta(text)
}
TaggedLineSegment::TagEnd(PlanTag::ProposedPlan) => ProposedPlanSegment::ProposedPlanEnd,
}
}
pub(crate) fn strip_proposed_plan_blocks(text: &str) -> String {
let mut parser = ProposedPlanParser::new();
let mut out = String::new();
for segment in parser.parse(text).into_iter().chain(parser.finish()) {
if let ProposedPlanSegment::Normal(delta) = segment {
out.push_str(&delta);
}
}
out
}
pub(crate) fn extract_proposed_plan_text(text: &str) -> Option<String> {
let mut parser = ProposedPlanParser::new();
let mut plan_text = String::new();
let mut saw_plan_block = false;
for segment in parser.parse(text).into_iter().chain(parser.finish()) {
match segment {
ProposedPlanSegment::ProposedPlanStart => {
saw_plan_block = true;
plan_text.clear();
}
ProposedPlanSegment::ProposedPlanDelta(delta) => {
plan_text.push_str(&delta);
}
ProposedPlanSegment::ProposedPlanEnd | ProposedPlanSegment::Normal(_) => {}
}
}
saw_plan_block.then_some(plan_text)
}
#[cfg(test)]
mod tests {
use super::ProposedPlanParser;
use super::ProposedPlanSegment;
use super::strip_proposed_plan_blocks;
use pretty_assertions::assert_eq;
#[test]
fn streams_proposed_plan_segments() {
let mut parser = ProposedPlanParser::new();
let mut segments = Vec::new();
for chunk in [
"Intro text\n<prop",
"osed_plan>\n- step 1\n",
"</proposed_plan>\nOutro",
] {
segments.extend(parser.parse(chunk));
}
segments.extend(parser.finish());
assert_eq!(
segments,
vec![
ProposedPlanSegment::Normal("Intro text\n".to_string()),
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
ProposedPlanSegment::Normal("Outro".to_string()),
]
);
}
#[test]
fn preserves_non_tag_lines() {
let mut parser = ProposedPlanParser::new();
let mut segments = parser.parse(" <proposed_plan> extra\n");
segments.extend(parser.finish());
assert_eq!(
segments,
vec![ProposedPlanSegment::Normal(
" <proposed_plan> extra\n".to_string()
)]
);
}
#[test]
fn closes_unterminated_plan_block_on_finish() {
let mut parser = ProposedPlanParser::new();
let mut segments = parser.parse("<proposed_plan>\n- step 1\n");
segments.extend(parser.finish());
assert_eq!(
segments,
vec![
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
]
);
}
#[test]
fn closes_tag_line_without_trailing_newline() {
let mut parser = ProposedPlanParser::new();
let mut segments = parser.parse("<proposed_plan>\n- step 1\n</proposed_plan>");
segments.extend(parser.finish());
assert_eq!(
segments,
vec![
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
]
);
}
#[test]
fn strips_proposed_plan_blocks_from_text() {
let text = "before\n<proposed_plan>\n- step\n</proposed_plan>\nafter";
assert_eq!(strip_proposed_plan_blocks(text), "before\nafter");
}
}
+13
View File
@@ -160,6 +160,7 @@ fn sanitize_rollout_item_for_persistence(
impl RolloutRecorder {
/// List threads (rollout files) under the provided Codex home directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_threads(
config: &Config,
page_size: usize,
@@ -168,6 +169,7 @@ impl RolloutRecorder {
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
config,
@@ -178,11 +180,13 @@ impl RolloutRecorder {
model_providers,
default_provider,
false,
search_term,
)
.await
}
/// List archived threads (rollout files) under the archived sessions directory.
#[allow(clippy::too_many_arguments)]
pub async fn list_archived_threads(
config: &Config,
page_size: usize,
@@ -191,6 +195,7 @@ impl RolloutRecorder {
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
default_provider: &str,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
Self::list_threads_with_db_fallback(
config,
@@ -201,6 +206,7 @@ impl RolloutRecorder {
model_providers,
default_provider,
true,
search_term,
)
.await
}
@@ -215,6 +221,7 @@ impl RolloutRecorder {
model_providers: Option<&[String]>,
default_provider: &str,
archived: bool,
search_term: Option<&str>,
) -> std::io::Result<ThreadsPage> {
let codex_home = config.codex_home.as_path();
// Filesystem-first listing intentionally overfetches so we can repair stale/missing
@@ -275,6 +282,7 @@ impl RolloutRecorder {
allowed_sources,
model_providers,
archived,
search_term,
)
.await
{
@@ -312,6 +320,7 @@ impl RolloutRecorder {
allowed_sources,
model_providers,
false,
None,
)
.await
else {
@@ -1154,6 +1163,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page1.items.len(), 1);
@@ -1168,6 +1178,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page2.items.len(), 1);
@@ -1229,6 +1240,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page.items.len(), 0);
@@ -1295,6 +1307,7 @@ mod tests {
&[],
None,
default_provider.as_str(),
None,
)
.await?;
assert_eq!(page.items.len(), 1);
+2
View File
@@ -226,6 +226,7 @@ pub async fn list_threads_db(
allowed_sources: &[SessionSource],
model_providers: Option<&[String]>,
archived: bool,
search_term: Option<&str>,
) -> Option<codex_state::ThreadsPage> {
let ctx = context?;
if ctx.codex_home() != codex_home {
@@ -257,6 +258,7 @@ pub async fn list_threads_db(
allowed_sources.as_slice(),
model_providers.as_deref(),
archived,
search_term,
)
.await
{
+141 -29
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use codex_protocol::config_types::ModeKind;
use codex_protocol::items::TurnItem;
use codex_utils_stream_parser::strip_citations;
use tokio_util::sync::CancellationToken;
use crate::codex::Session;
@@ -10,18 +11,75 @@ use crate::codex::TurnContext;
use crate::error::CodexErr;
use crate::error::Result;
use crate::function_tool::FunctionCallError;
use crate::memories::citations::get_thread_id_from_citations;
use crate::parse_turn_item;
use crate::proposed_plan_parser::strip_proposed_plan_blocks;
use crate::state_db;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolRouter;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_utils_stream_parser::strip_proposed_plan_blocks;
use futures::Future;
use tracing::debug;
use tracing::instrument;
fn strip_hidden_assistant_markup(text: &str, plan_mode: bool) -> String {
let (without_citations, _) = strip_citations(text);
if plan_mode {
strip_proposed_plan_blocks(&without_citations)
} else {
without_citations
}
}
pub(crate) fn raw_assistant_output_text_from_item(item: &ResponseItem) -> Option<String> {
if let ResponseItem::Message { role, content, .. } = item
&& role == "assistant"
{
let combined = content
.iter()
.filter_map(|ci| match ci {
codex_protocol::models::ContentItem::OutputText { text } => Some(text.as_str()),
_ => None,
})
.collect::<String>();
return Some(combined);
}
None
}
/// Persist a completed model response item and record any cited memory usage.
pub(crate) async fn record_completed_response_item(
sess: &Session,
turn_context: &TurnContext,
item: &ResponseItem,
) {
sess.record_conversation_items(turn_context, std::slice::from_ref(item))
.await;
record_stage1_output_usage_for_completed_item(turn_context, item).await;
}
async fn record_stage1_output_usage_for_completed_item(
turn_context: &TurnContext,
item: &ResponseItem,
) {
let Some(raw_text) = raw_assistant_output_text_from_item(item) else {
return;
};
let (_, citations) = strip_citations(&raw_text);
let thread_ids = get_thread_id_from_citations(citations);
if thread_ids.is_empty() {
return;
}
if let Some(db) = state_db::get_state_db(turn_context.config.as_ref(), None).await {
let _ = db.record_stage1_output_usage(&thread_ids).await;
}
}
/// Handle a completed output item from the model stream, recording it and
/// queuing any tool execution futures. This records items immediately so
/// history and rollout stay in sync even if the turn is later cancelled.
@@ -62,8 +120,7 @@ pub(crate) async fn handle_output_item_done(
payload_preview
);
ctx.sess
.record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item))
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
let cancellation_token = ctx.cancellation_token.child_token();
@@ -78,7 +135,7 @@ pub(crate) async fn handle_output_item_done(
}
// No tool call: convert messages/reasoning into turn items and mark them as complete.
Ok(None) => {
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode).await {
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode) {
if previously_active_item.is_none() {
ctx.sess
.emit_turn_item_started(&ctx.turn_context, &turn_item)
@@ -90,8 +147,7 @@ pub(crate) async fn handle_output_item_done(
.await;
}
ctx.sess
.record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item))
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
let last_agent_message = last_assistant_message_from_item(&item, plan_mode);
@@ -112,8 +168,7 @@ pub(crate) async fn handle_output_item_done(
..Default::default()
},
};
ctx.sess
.record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item))
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
if let Some(response_item) = response_input_to_response_item(&response) {
ctx.sess
@@ -135,8 +190,7 @@ pub(crate) async fn handle_output_item_done(
..Default::default()
},
};
ctx.sess
.record_conversation_items(&ctx.turn_context, std::slice::from_ref(&item))
record_completed_response_item(ctx.sess.as_ref(), ctx.turn_context.as_ref(), &item)
.await;
if let Some(response_item) = response_input_to_response_item(&response) {
ctx.sess
@@ -158,7 +212,7 @@ pub(crate) async fn handle_output_item_done(
Ok(output)
}
pub(crate) async fn handle_non_tool_response_item(
pub(crate) fn handle_non_tool_response_item(
item: &ResponseItem,
plan_mode: bool,
) -> Option<TurnItem> {
@@ -169,7 +223,7 @@ pub(crate) async fn handle_non_tool_response_item(
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. } => {
let mut turn_item = parse_turn_item(item)?;
if plan_mode && let TurnItem::AgentMessage(agent_message) = &mut turn_item {
if let TurnItem::AgentMessage(agent_message) = &mut turn_item {
let combined = agent_message
.content
.iter()
@@ -177,7 +231,7 @@ pub(crate) async fn handle_non_tool_response_item(
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
})
.collect::<String>();
let stripped = strip_proposed_plan_blocks(&combined);
let stripped = strip_hidden_assistant_markup(&combined, plan_mode);
agent_message.content =
vec![codex_protocol::items::AgentMessageContent::Text { text: stripped }];
}
@@ -195,25 +249,15 @@ pub(crate) fn last_assistant_message_from_item(
item: &ResponseItem,
plan_mode: bool,
) -> Option<String> {
if let ResponseItem::Message { role, content, .. } = item
&& role == "assistant"
{
let combined = content
.iter()
.filter_map(|ci| match ci {
codex_protocol::models::ContentItem::OutputText { text } => Some(text.as_str()),
_ => None,
})
.collect::<String>();
if let Some(combined) = raw_assistant_output_text_from_item(item) {
if combined.is_empty() {
return None;
}
return if plan_mode {
let stripped = strip_proposed_plan_blocks(&combined);
(!stripped.trim().is_empty()).then_some(stripped)
} else {
Some(combined)
};
let stripped = strip_hidden_assistant_markup(&combined, plan_mode);
if stripped.trim().is_empty() {
return None;
}
return Some(stripped);
}
None
}
@@ -248,3 +292,71 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::handle_non_tool_response_item;
use super::last_assistant_message_from_item;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use pretty_assertions::assert_eq;
fn assistant_output_text(text: &str) -> ResponseItem {
ResponseItem::Message {
id: Some("msg-1".to_string()),
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: text.to_string(),
}],
end_turn: Some(true),
phase: None,
}
}
#[test]
fn handle_non_tool_response_item_strips_citations_from_assistant_message() {
let item = assistant_output_text("hello<oai-mem-citation>doc1</oai-mem-citation> world");
let turn_item =
handle_non_tool_response_item(&item, false).expect("assistant message should parse");
let TurnItem::AgentMessage(agent_message) = turn_item else {
panic!("expected agent message");
};
let text = agent_message
.content
.iter()
.map(|entry| match entry {
codex_protocol::items::AgentMessageContent::Text { text } => text.as_str(),
})
.collect::<String>();
assert_eq!(text, "hello world");
}
#[test]
fn last_assistant_message_from_item_strips_citations_and_plan_blocks() {
let item = assistant_output_text(
"before<oai-mem-citation>doc1</oai-mem-citation>\n<proposed_plan>\n- x\n</proposed_plan>\nafter",
);
let message = last_assistant_message_from_item(&item, true)
.expect("assistant text should remain after stripping");
assert_eq!(message, "before\nafter");
}
#[test]
fn last_assistant_message_from_item_returns_none_for_citation_only_message() {
let item = assistant_output_text("<oai-mem-citation>doc1</oai-mem-citation>");
assert_eq!(last_assistant_message_from_item(&item, false), None);
}
#[test]
fn last_assistant_message_from_item_returns_none_for_plan_only_hidden_message() {
let item = assistant_output_text("<proposed_plan>\n- x\n</proposed_plan>");
assert_eq!(last_assistant_message_from_item(&item, true), None);
}
}
+35 -2
View File
@@ -291,6 +291,22 @@ impl ThreadManager {
config: Config,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.start_thread_with_tools_and_service_name(
config,
dynamic_tools,
persist_extended_history,
None,
)
.await
}
pub async fn start_thread_with_tools_and_service_name(
&self,
config: Config,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<NewThread> {
self.state
.spawn_thread(
@@ -300,6 +316,7 @@ impl ThreadManager {
self.agent_control(),
dynamic_tools,
persist_extended_history,
metrics_service_name,
)
.await
}
@@ -330,6 +347,7 @@ impl ThreadManager {
self.agent_control(),
Vec::new(),
persist_extended_history,
None,
)
.await
}
@@ -371,6 +389,7 @@ impl ThreadManager {
self.agent_control(),
Vec::new(),
persist_extended_history,
None,
)
.await
}
@@ -421,8 +440,14 @@ impl ThreadManagerState {
config: Config,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone(), false)
.await
self.spawn_new_thread_with_source(
config,
agent_control,
self.session_source.clone(),
false,
None,
)
.await
}
pub(crate) async fn spawn_new_thread_with_source(
@@ -431,6 +456,7 @@ impl ThreadManagerState {
agent_control: AgentControl,
session_source: SessionSource,
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -440,6 +466,7 @@ impl ThreadManagerState {
session_source,
Vec::new(),
persist_extended_history,
metrics_service_name,
)
.await
}
@@ -460,11 +487,13 @@ impl ThreadManagerState {
session_source,
Vec::new(),
false,
None,
)
.await
}
/// Spawn a new thread with optional history and register it with the manager.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn_thread(
&self,
config: Config,
@@ -473,6 +502,7 @@ impl ThreadManagerState {
agent_control: AgentControl,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -482,6 +512,7 @@ impl ThreadManagerState {
self.session_source.clone(),
dynamic_tools,
persist_extended_history,
metrics_service_name,
)
.await
}
@@ -496,6 +527,7 @@ impl ThreadManagerState {
session_source: SessionSource,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
metrics_service_name: Option<String>,
) -> CodexResult<NewThread> {
let watch_registration = self.file_watcher.register_config(&config);
let CodexSpawnOk {
@@ -511,6 +543,7 @@ impl ThreadManagerState {
agent_control,
dynamic_tools,
persist_extended_history,
metrics_service_name,
)
.await?;
self.finalize_thread_spawn(codex, thread_id, watch_registration)
@@ -1,5 +1,4 @@
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::max_thread_spawn_depth;
use crate::agent::next_thread_spawn_depth;
use crate::agent::status::is_final;
use crate::codex::Session;
@@ -531,7 +530,7 @@ async fn build_runner_options(
) -> Result<JobRunnerOptions, FunctionCallError> {
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"agent depth limit reached; this session cannot spawn more subagents".to_string(),
@@ -540,7 +539,7 @@ async fn build_runner_options(
let max_concurrency =
normalize_concurrency(requested_concurrency, turn.config.agent_max_threads);
let base_instructions = session.get_base_instructions().await;
let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref(), child_depth)?;
let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?;
Ok(JobRunnerOptions {
max_concurrency,
spawn_config,
@@ -1,10 +1,8 @@
use crate::agent::AgentStatus;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::max_thread_spawn_depth;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::config::Config;
use crate::config::Constrained;
use crate::error::CodexErr;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
@@ -18,7 +16,6 @@ use async_trait::async_trait;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentRef;
@@ -98,7 +95,6 @@ mod spawn {
use crate::agent::role::apply_role_to_config;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::max_thread_spawn_depth;
use crate::agent::next_thread_spawn_depth;
use std::sync::Arc;
@@ -131,7 +127,7 @@ mod spawn {
let prompt = input_preview(&input_items);
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
@@ -148,11 +144,8 @@ mod spawn {
.into(),
)
.await;
let mut config = build_agent_spawn_config(
&session.get_base_instructions().await,
turn.as_ref(),
child_depth,
)?;
let mut config =
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
apply_role_to_config(&mut config, role_name)
.await
.map_err(FunctionCallError::RespondToModel)?;
@@ -348,7 +341,7 @@ mod resume_agent {
.await
.unwrap_or((None, None));
let child_depth = next_thread_spawn_depth(&turn.session_source);
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
@@ -898,9 +891,8 @@ fn input_preview(items: &[UserInput]) -> String {
pub(crate) fn build_agent_spawn_config(
base_instructions: &BaseInstructions,
turn: &TurnContext,
child_depth: i32,
) -> Result<Config, FunctionCallError> {
let mut config = build_agent_shared_config(turn, child_depth)?;
let mut config = build_agent_shared_config(turn)?;
config.base_instructions = Some(base_instructions.text.clone());
Ok(config)
}
@@ -909,16 +901,14 @@ fn build_agent_resume_config(
turn: &TurnContext,
child_depth: i32,
) -> Result<Config, FunctionCallError> {
let mut config = build_agent_shared_config(turn, child_depth)?;
let mut config = build_agent_shared_config(turn)?;
apply_spawn_agent_overrides(&mut config, child_depth);
// For resume, keep base instructions sourced from rollout/session metadata.
config.base_instructions = None;
Ok(config)
}
fn build_agent_shared_config(
turn: &TurnContext,
child_depth: i32,
) -> Result<Config, FunctionCallError> {
fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallError> {
let base_config = turn.config.clone();
let mut config = (*base_config).clone();
config.model = Some(turn.model_info.slug.clone());
@@ -928,7 +918,6 @@ fn build_agent_shared_config(
config.developer_instructions = turn.developer_instructions.clone();
config.compact_prompt = turn.compact_prompt.clone();
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
apply_spawn_agent_overrides(&mut config, child_depth);
Ok(config)
}
@@ -937,6 +926,13 @@ fn apply_spawn_agent_runtime_overrides(
config: &mut Config,
turn: &TurnContext,
) -> Result<(), FunctionCallError> {
config
.permissions
.approval_policy
.set(turn.approval_policy.value())
.map_err(|err| {
FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}"))
})?;
config.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone();
config.cwd = turn.cwd.clone();
@@ -951,9 +947,7 @@ fn apply_spawn_agent_runtime_overrides(
}
fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never);
let max_depth = max_thread_spawn_depth(config.agent_max_spawn_depth);
if exceeds_thread_spawn_depth_limit(child_depth + 1, max_depth) {
if child_depth >= config.agent_max_depth {
config.features.disable(Feature::Collab);
}
}
@@ -964,7 +958,6 @@ mod tests {
use crate::AuthManager;
use crate::CodexAuth;
use crate::ThreadManager;
use crate::agent::max_thread_spawn_depth;
use crate::built_in_model_providers;
use crate::codex::make_session_and_context;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
@@ -1104,8 +1097,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "No role requiring it for now"]
async fn spawn_agent_uses_explorer_role_and_sets_never_approval_policy() {
async fn spawn_agent_uses_explorer_role_and_preserves_approval_policy() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
agent_id: String,
@@ -1121,6 +1113,9 @@ mod tests {
.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy should be set");
turn.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy should be set");
turn.config = Arc::new(config);
let invocation = invocation(
@@ -1158,8 +1153,7 @@ mod tests {
.expect("spawned agent thread should exist")
.config_snapshot()
.await;
assert_eq!(snapshot.model, "gpt-5.1-codex-mini");
assert_eq!(snapshot.approval_policy, AskForApproval::Never);
assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest);
}
#[tokio::test]
@@ -1210,6 +1204,9 @@ mod tests {
&turn.config.permissions.sandbox_policy,
turn.config.permissions.sandbox_policy.get().clone(),
);
turn.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy should be set");
turn.sandbox_policy
.set(expected_sandbox.clone())
.expect("sandbox policy should be set");
@@ -1256,7 +1253,7 @@ mod tests {
.config_snapshot()
.await;
assert_eq!(snapshot.sandbox_policy, expected_sandbox);
assert_eq!(snapshot.approval_policy, AskForApproval::Never);
assert_eq!(snapshot.approval_policy, AskForApproval::OnRequest);
}
#[tokio::test]
@@ -1265,7 +1262,7 @@ mod tests {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
let max_depth = turn.config.agent_max_depth;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: max_depth,
@@ -1696,7 +1693,7 @@ mod tests {
let manager = thread_manager();
session.services.agent_control = manager.agent_control();
let max_depth = max_thread_spawn_depth(turn.config.agent_max_spawn_depth);
let max_depth = turn.config.agent_max_depth;
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: session.conversation_id,
depth: max_depth,
@@ -2039,8 +2036,11 @@ mod tests {
turn.sandbox_policy
.set(sandbox_policy)
.expect("sandbox policy set");
turn.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy set");
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
let mut expected = (*turn.config).clone();
expected.base_instructions = Some(base_instructions.text);
expected.model = Some(turn.model_info.slug.clone());
@@ -2055,7 +2055,7 @@ mod tests {
expected
.permissions
.approval_policy
.set(AskForApproval::Never)
.set(AskForApproval::OnRequest)
.expect("approval policy set");
expected
.permissions
@@ -2076,7 +2076,7 @@ mod tests {
text: "base".to_string(),
};
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config");
assert_eq!(config.user_instructions, base_config.user_instructions);
}
@@ -2087,6 +2087,9 @@ mod tests {
let mut base_config = (*turn.config).clone();
base_config.base_instructions = Some("caller-base".to_string());
turn.config = Arc::new(base_config);
turn.approval_policy
.set(AskForApproval::OnRequest)
.expect("approval policy set");
let config = build_agent_resume_config(&turn, 0).expect("resume config");
@@ -2104,7 +2107,7 @@ mod tests {
expected
.permissions
.approval_policy
.set(AskForApproval::Never)
.set(AskForApproval::OnRequest)
.expect("approval policy set");
expected
.permissions
@@ -52,6 +52,47 @@ When to update memory:
- When user explicitly asks to remember/update memory, revise memory_summary.md
and/or MEMORY.md.
Memory citation requirements:
- If ANY relevant memory files were used: append exactly one
`<oai-mem-citation>` block as the VERY LAST content of the final reply.
Normal responses should include the answer first, then append the
`<oai-mem-citation>` block at the end.
- Use this exact structure for programmatic parsing:
```
<oai-mem-citation>
<citation_entries>
MEMORY.md:234-236|note=[responsesapi citation extraction code pointer]
rollout_summaries/2026-02-17T21-23-02-LN3m-weekly_memory_report_pivot_from_git_history.md:10-12|note=[weekly report format]
</citation_entries>
<rollout_ids>
019c6e27-e55b-73d1-87d8-4e01f1f75043
019c7714-3b77-74d1-9866-e1f484aae2ab
</rollout_ids>
</oai-mem-citation>
```
- `citation_entries` is for rendering:
- one citation entry per line
- format: `<file>:<line_start>-<line_end>|note=[<how memory was used>]`
- use file paths relative to the memory base path (for example, `MEMORY.md`,
`rollout_summaries/...`, `skills/...`)
- only cite files actually used under the memory base path (do not cite
workspace files as memory citations)
- if you used `MEMORY.md` and then a rollout summary/skill file, cite both
- list entries in order of importance (most important first)
- `note` should be short, single-line, and use simple characters only (avoid
unusual symbols, no newlines)
- `rollout_ids` is for us to track what previous rollouts you find useful:
- include one rollout id per line
- rollout ids should look like UUIDs (for example,
`019c6e27-e55b-73d1-87d8-4e01f1f75043`)
- include unique ids only; do not repeat ids
- an empty `<rollout_ids>` section is allowed if no rollout ids are available
- you can find rollout ids in rollout summary files and MEMORY.md
- do not include file paths or notes in this section
- Never include memory citations inside pull-request messages.
- Never cite blank lines; double-check ranges.
========= MEMORY_SUMMARY BEGINS =========
{{ memory_summary }}
========= MEMORY_SUMMARY ENDS =========
+30 -29
View File
@@ -1,6 +1,7 @@
#![allow(clippy::expect_used)]
use anyhow::Result;
use codex_test_macros::large_stack_test;
use core_test_support::responses::ev_apply_patch_call;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_shell_command_call;
@@ -85,7 +86,7 @@ fn apply_patch_responses(
]
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -132,7 +133,7 @@ D delete.txt
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -159,7 +160,7 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -188,7 +189,7 @@ async fn apply_patch_cli_moves_file_to_new_directory(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -216,7 +217,7 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -242,7 +243,7 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -273,7 +274,7 @@ async fn apply_patch_cli_move_overwrites_existing_destination(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -334,7 +335,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -360,7 +361,7 @@ async fn apply_patch_cli_add_overwrites_existing_file(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -392,7 +393,7 @@ async fn apply_patch_cli_rejects_invalid_hunk_header(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -426,7 +427,7 @@ async fn apply_patch_cli_reports_missing_context(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -462,7 +463,7 @@ async fn apply_patch_cli_reports_missing_target_file(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -499,7 +500,7 @@ async fn apply_patch_cli_delete_missing_file_reports_error(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -524,7 +525,7 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -551,7 +552,7 @@ async fn apply_patch_cli_delete_directory_reports_verification_error(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -605,7 +606,7 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -660,7 +661,7 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -694,7 +695,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -732,7 +733,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -862,7 +863,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -945,7 +946,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1021,7 +1022,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::ShellViaHeredoc)]
#[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)]
async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
@@ -1044,7 +1045,7 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1067,7 +1068,7 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput)
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1102,7 +1103,7 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1161,7 +1162,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
@@ -1227,7 +1228,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1295,7 +1296,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1385,7 +1386,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[large_stack_test]
#[test_case(ApplyPatchModelOutput::Freeform)]
#[test_case(ApplyPatchModelOutput::Function)]
#[test_case(ApplyPatchModelOutput::Shell)]
+308
View File
@@ -505,6 +505,314 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
session_configured,
..
} = test_codex().build(&server).await?;
let added_text = "Intro <oai-mem-";
let deltas = [
"citation>outer-doc</oai-mem-citation>\n<proposed",
"_plan>\n- Step 1<oai-mem-",
"citation>plan-doc</oai-mem-citation>\n- Step 2\n</proposed_plan>\nOu",
"tro",
];
let full_message = format!("{added_text}{}", deltas.concat());
let mut events = vec![
ev_response_created("resp-1"),
ev_message_item_added("msg-1", added_text),
];
for delta in deltas {
events.push(ev_output_text_delta(delta));
}
events.push(ev_assistant_message("msg-1", &full_message));
events.push(ev_completed("resp-1"));
mount_sse_once(&server, sse(events)).await;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
};
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please plan with citations".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: std::env::current_dir()?,
approval_policy: codex_protocol::protocol::AskForApproval::Never,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
model: session_configured.model.clone(),
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
let mut agent_started = None;
let mut agent_started_idx = None;
let mut agent_completed = None;
let mut agent_completed_idx = None;
let mut plan_started = None;
let mut plan_started_idx = None;
let mut plan_completed = None;
let mut plan_completed_idx = None;
let mut agent_deltas = Vec::new();
let mut plan_deltas = Vec::new();
let mut first_agent_delta_idx = None;
let mut last_agent_delta_idx = None;
let mut first_plan_delta_idx = None;
let mut last_plan_delta_idx = None;
let mut idx = 0usize;
let turn_complete_idx = loop {
let ev = wait_for_event(&codex, |_| true).await;
match ev {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::AgentMessage(item),
..
}) => {
agent_started_idx = Some(idx);
agent_started = Some(item);
}
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::Plan(item),
..
}) => {
plan_started_idx = Some(idx);
plan_started = Some(item);
}
EventMsg::AgentMessageContentDelta(event) => {
if first_agent_delta_idx.is_none() {
first_agent_delta_idx = Some(idx);
}
last_agent_delta_idx = Some(idx);
agent_deltas.push(event.delta);
}
EventMsg::PlanDelta(event) => {
if first_plan_delta_idx.is_none() {
first_plan_delta_idx = Some(idx);
}
last_plan_delta_idx = Some(idx);
plan_deltas.push(event.delta);
}
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::AgentMessage(item),
..
}) => {
agent_completed_idx = Some(idx);
agent_completed = Some(item);
}
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::Plan(item),
..
}) => {
plan_completed_idx = Some(idx);
plan_completed = Some(item);
}
EventMsg::TurnComplete(_) => {
break idx;
}
_ => {}
}
idx += 1;
};
let agent_started = agent_started.expect("agent item start should be emitted");
let agent_completed = agent_completed.expect("agent item completion should be emitted");
let plan_started = plan_started.expect("plan item start should be emitted");
let plan_completed = plan_completed.expect("plan item completion should be emitted");
assert_eq!(agent_started.id, agent_completed.id);
assert_eq!(plan_started.id, plan_completed.id);
assert_eq!(plan_started.text, "");
let agent_started_text: String = agent_started
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect();
let agent_completed_text: String = agent_completed
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect();
let agent_delta_text = agent_deltas.concat();
let plan_delta_text = plan_deltas.concat();
assert_eq!(agent_started_text, "");
assert_eq!(agent_delta_text, "Intro \nOutro");
assert_eq!(agent_completed_text, "Intro \nOutro");
assert_eq!(plan_delta_text, "- Step 1\n- Step 2\n");
assert_eq!(plan_completed.text, "- Step 1\n- Step 2\n");
for text in [
agent_started_text.as_str(),
agent_delta_text.as_str(),
agent_completed_text.as_str(),
plan_delta_text.as_str(),
plan_completed.text.as_str(),
] {
assert!(!text.contains("<oai-mem-citation>"));
assert!(!text.contains("</oai-mem-citation>"));
}
let agent_started_idx = agent_started_idx.expect("agent start index");
let agent_completed_idx = agent_completed_idx.expect("agent completion index");
let plan_started_idx = plan_started_idx.expect("plan start index");
let plan_completed_idx = plan_completed_idx.expect("plan completion index");
let first_agent_delta_idx = first_agent_delta_idx.expect("agent delta index");
let last_agent_delta_idx = last_agent_delta_idx.expect("agent delta index");
let first_plan_delta_idx = first_plan_delta_idx.expect("plan delta index");
let last_plan_delta_idx = last_plan_delta_idx.expect("plan delta index");
assert!(agent_started_idx < first_agent_delta_idx);
assert!(plan_started_idx < first_plan_delta_idx);
assert!(last_agent_delta_idx < agent_completed_idx);
assert!(last_plan_delta_idx < plan_completed_idx);
assert!(agent_completed_idx < turn_complete_idx);
assert!(plan_completed_idx < turn_complete_idx);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_parsed()
-> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let TestCodex {
codex,
session_configured,
..
} = test_codex().build(&server).await?;
let added_text = "Intro\n<proposed";
let deltas = ["_plan>\n- Step 1\n</proposed_plan>\nOutro"];
let full_message = format!("{added_text}{}", deltas.concat());
let mut events = vec![
ev_response_created("resp-1"),
ev_message_item_added("msg-1", added_text),
];
for delta in deltas {
events.push(ev_output_text_delta(delta));
}
events.push(ev_assistant_message("msg-1", &full_message));
events.push(ev_completed("resp-1"));
mount_sse_once(&server, sse(events)).await;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Plan,
settings: Settings {
model: session_configured.model.clone(),
reasoning_effort: None,
developer_instructions: None,
},
};
codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "please plan".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: std::env::current_dir()?,
approval_policy: codex_protocol::protocol::AskForApproval::Never,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
model: session_configured.model.clone(),
effort: None,
summary: codex_protocol::config_types::ReasoningSummary::Auto,
collaboration_mode: Some(collaboration_mode),
personality: None,
})
.await?;
let mut agent_started = None;
let mut agent_completed = None;
let mut plan_started = None;
let mut plan_completed = None;
let mut agent_deltas = Vec::new();
let mut plan_deltas = Vec::new();
loop {
let ev = wait_for_event(&codex, |_| true).await;
match ev {
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::AgentMessage(item),
..
}) => agent_started = Some(item),
EventMsg::ItemStarted(ItemStartedEvent {
item: TurnItem::Plan(item),
..
}) => plan_started = Some(item),
EventMsg::AgentMessageContentDelta(event) => agent_deltas.push(event.delta),
EventMsg::PlanDelta(event) => plan_deltas.push(event.delta),
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::AgentMessage(item),
..
}) => agent_completed = Some(item),
EventMsg::ItemCompleted(ItemCompletedEvent {
item: TurnItem::Plan(item),
..
}) => plan_completed = Some(item),
EventMsg::TurnComplete(_) => break,
_ => {}
}
}
let agent_started = agent_started.expect("agent item start should be emitted");
let agent_completed = agent_completed.expect("agent item completion should be emitted");
let plan_started = plan_started.expect("plan item start should be emitted");
let plan_completed = plan_completed.expect("plan item completion should be emitted");
let agent_started_text: String = agent_started
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect();
let agent_completed_text: String = agent_completed
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect();
assert_eq!(agent_started_text, "");
assert_eq!(agent_deltas.concat(), "Intro\nOutro");
assert_eq!(agent_completed_text, "Intro\nOutro");
assert_eq!(plan_started.text, "");
assert_eq!(plan_deltas.concat(), "- Step 1\n");
assert_eq!(plan_completed.text, "- Step 1\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
-1
View File
@@ -86,7 +86,6 @@ mod live_reload;
mod model_info_overrides;
mod model_overrides;
mod model_switching;
mod model_tools;
mod model_visible_layout;
mod models_cache_ttl;
mod models_etag_responses;
-149
View File
@@ -1,149 +0,0 @@
#![allow(clippy::unwrap_used)]
use codex_core::features::Feature;
use codex_protocol::config_types::WebSearchMode;
use core_test_support::responses;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
#[allow(clippy::expect_used)]
fn tool_identifiers(body: &serde_json::Value) -> Vec<String> {
body["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| {
tool.get("name")
.and_then(|v| v.as_str())
.or_else(|| tool.get("type").and_then(|v| v.as_str()))
.map(std::string::ToString::to_string)
.expect("tool should have either name or type")
})
.collect()
}
#[allow(clippy::expect_used)]
async fn collect_tool_identifiers_for_model(model: &str) -> Vec<String> {
let server = start_mock_server().await;
let sse = responses::sse(vec![
responses::ev_response_created(model),
responses::ev_completed(model),
]);
let resp_mock = responses::mount_sse_once(&server, sse).await;
let mut builder = test_codex()
.with_model(model)
// Keep tool expectations stable when the default web_search mode changes.
.with_config(|config| {
config
.web_search_mode
.set(WebSearchMode::Cached)
.expect("test web_search_mode should satisfy constraints");
config.features.enable(Feature::CollaborationModes);
});
let test = builder
.build(&server)
.await
.expect("create test Codex conversation");
test.submit_turn("hello tools").await.expect("submit turn");
let body = resp_mock.single_request().body_json();
tool_identifiers(&body)
}
fn expected_default_tools(shell_tool: &str, tail: &[&str]) -> Vec<String> {
let mut tools = if cfg!(windows) {
vec![shell_tool.to_string()]
} else {
vec!["exec_command".to_string(), "write_stdin".to_string()]
};
tools.extend(tail.iter().map(|tool| (*tool).to_string()));
tools
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn model_selects_expected_tools() {
skip_if_no_network!();
use pretty_assertions::assert_eq;
let gpt51_codex_max_tools = collect_tool_identifiers_for_model("gpt-5.1-codex-max").await;
assert_eq!(
gpt51_codex_max_tools,
expected_default_tools(
"shell_command",
&[
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
],
),
"gpt-5.1-codex-max should expose the apply_patch tool",
);
let gpt5_codex_tools = collect_tool_identifiers_for_model("gpt-5-codex").await;
assert_eq!(
gpt5_codex_tools,
expected_default_tools(
"shell_command",
&[
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
],
),
"gpt-5-codex should expose the apply_patch tool",
);
let gpt51_codex_tools = collect_tool_identifiers_for_model("gpt-5.1-codex").await;
assert_eq!(
gpt51_codex_tools,
expected_default_tools(
"shell_command",
&[
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
],
),
"gpt-5.1-codex should expose the apply_patch tool",
);
let gpt5_tools = collect_tool_identifiers_for_model("gpt-5").await;
assert_eq!(
gpt5_tools,
expected_default_tools(
"shell",
&[
"update_plan",
"request_user_input",
"web_search",
"view_image",
],
),
"gpt-5 should expose the apply_patch tool",
);
let gpt51_tools = collect_tool_identifiers_for_model("gpt-5.1").await;
assert_eq!(
gpt51_tools,
expected_default_tools(
"shell_command",
&[
"update_plan",
"request_user_input",
"apply_patch",
"web_search",
"view_image",
],
),
"gpt-5.1 should expose the apply_patch tool",
);
}
+1
View File
@@ -181,6 +181,7 @@ impl AppServerClient {
source_kinds: None,
archived: None,
cwd: None,
search_term: None,
},
};
self.send(&request)?;
+2 -1
View File
@@ -81,7 +81,8 @@ Interrupt a running turn: `interruptConversation`.
List/resume/archive: `listConversations`, `resumeConversation`, `archiveConversation`.
For v2 threads, use `thread/list` with filters such as `archived: true` or `cwd: "/path"` to
For v2 threads, use `thread/list` with filters such as `archived: true`, `cwd: "/path"`, or
`searchTerm: "needle"` to
narrow results, and `thread/unarchive` to restore archived rollouts to the active sessions
directory (it returns the restored thread summary).
+5
View File
@@ -278,6 +278,11 @@ fn exec_resume_last_respects_cwd_filter_and_all_flag() -> anyhow::Result<()> {
.assert()
.success();
// `resume --last` sorts by `updated_at`, which is second-granularity. Sleep so
// the upcoming `resume --last --all` write lands in a later second and becomes
// deterministically newest (instead of tying and falling back to UUID order).
std::thread::sleep(std::time::Duration::from_millis(1100));
let marker_b2 = format!("resume-cwd-b-2-{}", Uuid::new_v4());
let prompt_b2 = format!("echo {marker_b2}");
test.cmd()
+1
View File
@@ -15,6 +15,7 @@ workspace = true
anyhow = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true, features = ["derive"] }
chrono = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
+39
View File
@@ -137,6 +137,45 @@ the decider can auto-allow network requests originating from that command.
**Important:** Explicit deny rules still win. The decider only gets a chance to override
`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`.
## OTEL Audit Events (embedded/managed)
When `codex-network-proxy` is embedded in managed Codex runtime, policy decisions emit structured
OTEL-compatible events with `target=codex_otel.network_proxy`.
Event name:
- `codex.network_proxy.policy_decision`
- emitted for each policy decision (`domain` and `non_domain`).
- `network.policy.scope = "domain"` for host-policy evaluations (`evaluate_host_policy`).
- `network.policy.scope = "non_domain"` for mode-guard/proxy-state checks (including unix-socket guard paths and unix-socket allow decisions).
Common fields:
- `event.name`
- `event.timestamp` (RFC3339 UTC, millisecond precision)
- optional metadata:
- `conversation.id`
- `app.version`
- `user.account_id`
- policy/network:
- `network.policy.scope` (`domain` or `non_domain`)
- `network.policy.decision` (`allow`, `deny`, or `ask`)
- `network.policy.source` (`baseline_policy`, `mode_guard`, `proxy_state`, `decider`)
- `network.policy.reason`
- `network.transport.protocol`
- `server.address`
- `server.port`
- `http.request.method` (defaults to `"none"` when absent)
- `client.address` (defaults to `"unknown"` when absent)
- `network.policy.override` (`true` only when decider-allow overrides baseline `not_allowed`)
Unix-socket block-path audits use sentinel endpoint values:
- `server.address = "unix-socket"`
- `server.port = 0`
Audit events intentionally avoid logging full URL/path/query data.
## Admin API
The admin API is a small HTTP server intended for debugging and runtime adjustments.
+183
View File
@@ -1,5 +1,6 @@
use crate::config::NetworkMode;
use crate::mitm;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -7,12 +8,15 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::emit_allow_decision_audit_event;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_MITM_REQUIRED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_PROXY_DISABLED;
use crate::reasons::REASON_UNIX_SOCKET_UNSUPPORTED;
use crate::responses::PolicyDecisionDetails;
use crate::responses::blocked_header_value;
use crate::responses::blocked_message_with_policy;
@@ -176,6 +180,7 @@ async fn http_connect_accept(
client_addr(&req),
Some("CONNECT".to_string()),
NetworkProtocol::HttpsConnect,
None,
)
.await);
}
@@ -247,6 +252,18 @@ async fn http_connect_accept(
if mode == NetworkMode::Limited && mitm_state.is_none() {
// Limited mode is designed to be read-only. Without MITM, a CONNECT tunnel would hide the
// inner HTTP method/headers from the proxy, effectively bypassing method policy.
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_MITM_REQUIRED,
protocol: NetworkProtocol::HttpsConnect,
server_address: host.as_str(),
server_port: authority.port,
method: Some("CONNECT"),
client_addr: client.as_deref(),
},
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_MITM_REQUIRED,
@@ -449,10 +466,23 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
Some(("unix-socket", 0)),
)
.await);
}
if !method_allowed {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
let method = req.method();
warn!(
@@ -462,6 +492,18 @@ async fn http_plain_proxy(
}
if !unix_socket_permissions_supported() {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_UNIX_SOCKET_UNSUPPORTED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
warn!("unix socket proxy unsupported on this platform (path={socket_path})");
return Ok(text_response(
StatusCode::NOT_IMPLEMENTED,
@@ -471,6 +513,18 @@ async fn http_plain_proxy(
return match app_state.is_unix_socket_allowed(&socket_path).await {
Ok(true) => {
emit_http_allow_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: "allow",
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
info!("unix socket allowed (client={client}, path={socket_path})");
match proxy_via_unix_socket(req, &socket_path).await {
@@ -485,6 +539,18 @@ async fn http_plain_proxy(
}
}
Ok(false) => {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let client = client.as_deref().unwrap_or_default();
warn!("unix socket blocked (client={client}, path={socket_path})");
Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED, None))
@@ -528,6 +594,7 @@ async fn http_plain_proxy(
client_addr(&req),
Some(req.method().as_str().to_string()),
NetworkProtocol::Http,
None,
)
.await);
}
@@ -581,6 +648,18 @@ async fn http_plain_proxy(
}
if !method_allowed {
emit_http_block_decision_audit_event(
&app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: host.as_str(),
server_port: port,
method: Some(req.method().as_str()),
client_addr: client.as_deref(),
},
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -794,7 +873,23 @@ async fn proxy_disabled_response(
client: Option<String>,
method: Option<String>,
protocol: NetworkProtocol,
audit_endpoint_override: Option<(&str, u16)>,
) -> Response {
let (audit_server_address, audit_server_port) =
audit_endpoint_override.unwrap_or((host.as_str(), port));
emit_http_block_decision_audit_event(
app_state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ProxyState,
reason: REASON_PROXY_DISABLED,
protocol,
server_address: audit_server_address,
server_port: audit_server_port,
method: method.as_deref(),
client_addr: client.as_deref(),
},
);
let blocked_host = host.clone();
let _ = app_state
.record_blocked(BlockedRequest::new(BlockedRequestArgs {
@@ -837,6 +932,20 @@ fn text_response(status: StatusCode, body: &str) -> Response {
.unwrap_or_else(|_| Response::new(Body::from(body.to_string())))
}
fn emit_http_block_decision_audit_event(
app_state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_block_decision_audit_event(app_state, args);
}
fn emit_http_allow_decision_audit_event(
app_state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_allow_decision_audit_event(app_state, args);
}
#[derive(Serialize)]
struct BlockedResponse<'a> {
status: &'static str,
@@ -911,6 +1020,80 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_blocks_unix_socket_when_method_not_allowed() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
state
.set_network_mode(NetworkMode::Limited)
.await
.expect("network mode should update");
let mut req = Request::builder()
.method(Method::POST)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-method-policy"
);
}
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_rejects_unix_socket_when_not_allowlisted() {
let state = Arc::new(network_proxy_state_for_policy(
NetworkProxySettings::default(),
));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
if cfg!(target_os = "macos") {
assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert_eq!(
response.headers().get("x-proxy-error").unwrap(),
"blocked-by-allowlist"
);
} else {
assert_eq!(response.status(), StatusCode::NOT_IMPLEMENTED);
}
}
#[cfg(target_os = "macos")]
#[tokio::test(flavor = "current_thread")]
async fn http_plain_proxy_attempts_allowed_unix_socket_proxy() {
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
allow_unix_sockets: vec!["/tmp/test.sock".to_string()],
..NetworkProxySettings::default()
}));
let mut req = Request::builder()
.method(Method::GET)
.uri("http://example.com")
.header("x-unix-socket", "/tmp/test.sock")
.body(Body::empty())
.expect("request should build");
req.extensions_mut().insert(state);
let response = http_plain_proxy(None, req).await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
}
#[tokio::test]
async fn http_connect_accept_denies_denylisted_host() {
let policy = NetworkProxySettings {
+1
View File
@@ -43,6 +43,7 @@ pub use runtime::BlockedRequestObserver;
pub use runtime::ConfigReloader;
pub use runtime::ConfigState;
pub use runtime::NetworkProxyState;
pub use state::NetworkProxyAuditMetadata;
pub use state::NetworkProxyConstraintError;
pub use state::NetworkProxyConstraints;
pub use state::PartialNetworkConfig;
+582 -49
View File
@@ -4,9 +4,21 @@ use crate::runtime::HostBlockReason;
use crate::state::NetworkProxyState;
use anyhow::Result;
use async_trait::async_trait;
use chrono::SecondsFormat;
use chrono::Utc;
use std::future::Future;
use std::sync::Arc;
const AUDIT_TARGET: &str = "codex_otel.network_proxy";
const POLICY_DECISION_EVENT_NAME: &str = "codex.network_proxy.policy_decision";
const POLICY_SCOPE_DOMAIN: &str = "domain";
const POLICY_SCOPE_NON_DOMAIN: &str = "non_domain";
const POLICY_DECISION_ALLOW: &str = "allow";
const POLICY_DECISION_DENY: &str = "deny";
const POLICY_REASON_ALLOW: &str = "allow";
const DEFAULT_METHOD: &str = "none";
const DEFAULT_CLIENT_ADDRESS: &str = "unknown";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NetworkProtocol {
Http,
@@ -154,6 +166,98 @@ impl NetworkDecision {
}
}
pub(crate) struct BlockDecisionAuditEventArgs<'a> {
pub source: NetworkDecisionSource,
pub reason: &'a str,
pub protocol: NetworkProtocol,
pub server_address: &'a str,
pub server_port: u16,
pub method: Option<&'a str>,
pub client_addr: Option<&'a str>,
}
pub(crate) fn emit_block_decision_audit_event(
state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_DENY);
}
pub(crate) fn emit_allow_decision_audit_event(
state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
) {
emit_non_domain_policy_decision_audit_event(state, args, POLICY_DECISION_ALLOW);
}
fn emit_non_domain_policy_decision_audit_event(
state: &NetworkProxyState,
args: BlockDecisionAuditEventArgs<'_>,
decision: &'static str,
) {
emit_policy_audit_event(
state,
PolicyAuditEventArgs {
scope: POLICY_SCOPE_NON_DOMAIN,
decision,
source: args.source.as_str(),
reason: args.reason,
protocol: args.protocol,
server_address: args.server_address,
server_port: args.server_port,
method: args.method,
client_addr: args.client_addr,
policy_override: false,
},
);
}
struct PolicyAuditEventArgs<'a> {
scope: &'static str,
decision: &'a str,
source: &'a str,
reason: &'a str,
protocol: NetworkProtocol,
server_address: &'a str,
server_port: u16,
method: Option<&'a str>,
client_addr: Option<&'a str>,
policy_override: bool,
}
fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs<'_>) {
let metadata = state.audit_metadata();
tracing::event!(
target: AUDIT_TARGET,
tracing::Level::INFO,
event.name = POLICY_DECISION_EVENT_NAME,
event.timestamp = %audit_timestamp(),
conversation.id = metadata.conversation_id.as_deref(),
app.version = metadata.app_version.as_deref(),
auth_mode = metadata.auth_mode.as_deref(),
originator = metadata.originator.as_deref(),
user.account_id = metadata.user_account_id.as_deref(),
user.email = metadata.user_email.as_deref(),
terminal.type = metadata.terminal_type.as_deref(),
model = metadata.model.as_deref(),
slug = metadata.slug.as_deref(),
network.policy.scope = args.scope,
network.policy.decision = args.decision,
network.policy.source = args.source,
network.policy.reason = args.reason,
network.transport.protocol = args.protocol.as_policy_protocol(),
server.address = args.server_address,
server.port = args.server_port,
http.request.method = args.method.unwrap_or(DEFAULT_METHOD),
client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS),
network.policy.override = args.policy_override,
);
}
fn audit_timestamp() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
/// Decide whether a network request should be allowed.
///
/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy
@@ -187,23 +291,71 @@ pub(crate) async fn evaluate_host_policy(
decider: Option<&Arc<dyn NetworkPolicyDecider>>,
request: &NetworkPolicyRequest,
) -> Result<NetworkDecision> {
match state.host_blocked(&request.host, request.port).await? {
HostBlockDecision::Allowed => Ok(NetworkDecision::Allow),
let host_decision = state.host_blocked(&request.host, request.port).await?;
let (decision, policy_override) = match host_decision {
HostBlockDecision::Allowed => (NetworkDecision::Allow, false),
HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => {
if let Some(decider) = decider {
Ok(map_decider_decision(decider.decide(request.clone()).await))
let decider_decision = map_decider_decision(decider.decide(request.clone()).await);
let policy_override = matches!(decider_decision, NetworkDecision::Allow);
(decider_decision, policy_override)
} else {
Ok(NetworkDecision::deny_with_source(
HostBlockReason::NotAllowed.as_str(),
NetworkDecisionSource::BaselinePolicy,
))
(
NetworkDecision::deny_with_source(
HostBlockReason::NotAllowed.as_str(),
NetworkDecisionSource::BaselinePolicy,
),
false,
)
}
}
HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny_with_source(
reason.as_str(),
NetworkDecisionSource::BaselinePolicy,
)),
}
HostBlockDecision::Blocked(reason) => (
NetworkDecision::deny_with_source(
reason.as_str(),
NetworkDecisionSource::BaselinePolicy,
),
false,
),
};
let (policy_decision, source, reason) = match &decision {
NetworkDecision::Allow => (
POLICY_DECISION_ALLOW,
if policy_override {
NetworkDecisionSource::Decider
} else {
NetworkDecisionSource::BaselinePolicy
},
if policy_override {
HostBlockReason::NotAllowed.as_str()
} else {
POLICY_REASON_ALLOW
},
),
NetworkDecision::Deny {
reason,
source,
decision,
} => (decision.as_str(), *source, reason.as_str()),
};
emit_policy_audit_event(
state,
PolicyAuditEventArgs {
scope: POLICY_SCOPE_DOMAIN,
decision: policy_decision,
source: source.as_str(),
reason,
protocol: request.protocol,
server_address: request.host.as_str(),
server_port: request.port,
method: request.method.as_deref(),
client_addr: request.client_addr.as_deref(),
policy_override,
},
);
Ok(decision)
}
fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
@@ -219,21 +371,244 @@ fn map_decider_decision(decision: NetworkDecision) -> NetworkDecision {
}
}
#[cfg(test)]
pub(crate) mod test_support {
pub(crate) const POLICY_DECISION_EVENT_NAME: &str = super::POLICY_DECISION_EVENT_NAME;
use std::collections::BTreeMap;
use std::fmt;
use std::future::Future;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tracing::Event;
use tracing::Id;
use tracing::Metadata;
use tracing::Subscriber;
use tracing::field::Field;
use tracing::field::Visit;
use tracing::span::Attributes;
use tracing::span::Record;
use tracing::subscriber::Interest;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CapturedEvent {
pub target: String,
pub fields: BTreeMap<String, String>,
}
impl CapturedEvent {
pub fn field(&self, name: &str) -> Option<&str> {
self.fields.get(name).map(String::as_str)
}
}
#[derive(Clone, Default)]
struct EventCollector {
events: Arc<Mutex<Vec<CapturedEvent>>>,
next_span_id: Arc<AtomicU64>,
}
impl EventCollector {
fn events(&self) -> Vec<CapturedEvent> {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl Subscriber for EventCollector {
fn enabled(&self, _metadata: &Metadata<'_>) -> bool {
true
}
fn register_callsite(&self, _metadata: &'static Metadata<'static>) -> Interest {
Interest::always()
}
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
Some(tracing::level_filters::LevelFilter::TRACE)
}
fn new_span(&self, _span: &Attributes<'_>) -> Id {
Id::from_u64(self.next_span_id.fetch_add(1, Ordering::Relaxed) + 1)
}
fn record(&self, _span: &Id, _values: &Record<'_>) {}
fn record_follows_from(&self, _span: &Id, _follows: &Id) {}
fn event(&self, event: &Event<'_>) {
let mut visitor = FieldVisitor::default();
event.record(&mut visitor);
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(CapturedEvent {
target: event.metadata().target().to_string(),
fields: visitor.fields,
});
}
fn enter(&self, _span: &Id) {}
fn exit(&self, _span: &Id) {}
}
#[derive(Default)]
struct FieldVisitor {
fields: BTreeMap<String, String>,
}
impl FieldVisitor {
fn insert(&mut self, field: &Field, value: impl Into<String>) {
self.fields.insert(field.name().to_string(), value.into());
}
}
impl Visit for FieldVisitor {
fn record_str(&mut self, field: &Field, value: &str) {
self.insert(field, value);
}
fn record_bool(&mut self, field: &Field, value: bool) {
self.insert(field, value.to_string());
}
fn record_i64(&mut self, field: &Field, value: i64) {
self.insert(field, value.to_string());
}
fn record_u64(&mut self, field: &Field, value: u64) {
self.insert(field, value.to_string());
}
fn record_i128(&mut self, field: &Field, value: i128) {
self.insert(field, value.to_string());
}
fn record_u128(&mut self, field: &Field, value: u128) {
self.insert(field, value.to_string());
}
fn record_f64(&mut self, field: &Field, value: f64) {
self.insert(field, value.to_string());
}
fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) {
self.insert(field, value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
self.insert(field, format!("{value:?}"));
}
}
pub(crate) async fn capture_events<F, Fut, T>(f: F) -> (T, Vec<CapturedEvent>)
where
F: FnOnce() -> Fut,
Fut: Future<Output = T>,
{
let collector = EventCollector::default();
let _guard = tracing::subscriber::set_default(collector.clone());
let output = f().await;
let events = collector.events();
(output, events)
}
pub(crate) fn find_event_by_name<'a>(
events: &'a [CapturedEvent],
event_name: &str,
) -> Option<&'a CapturedEvent> {
events
.iter()
.find(|event| event.field("event.name") == Some(event_name))
}
}
#[cfg(test)]
mod tests {
use super::test_support::capture_events;
use super::test_support::find_event_by_name;
use super::*;
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkProxySettings;
use crate::reasons::REASON_DENIED;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
use crate::reasons::REASON_NOT_ALLOWED;
use crate::reasons::REASON_NOT_ALLOWED_LOCAL;
use crate::runtime::ConfigReloader;
use crate::runtime::ConfigState;
use crate::runtime::NetworkProxyAuditMetadata;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use crate::state::network_proxy_state_for_policy;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
#[tokio::test]
async fn evaluate_host_policy_invokes_decider_for_not_allowed() {
const LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME: &str =
"codex.network_proxy.domain_policy_decision";
const LEGACY_BLOCK_DECISION_EVENT_NAME: &str = "codex.network_proxy.block_decision";
#[derive(Clone)]
struct StaticReloader {
state: ConfigState,
}
#[async_trait]
impl ConfigReloader for StaticReloader {
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
Ok(self.state.clone())
}
fn source_label(&self) -> String {
"static test reloader".to_string()
}
}
fn state_with_metadata(metadata: NetworkProxyAuditMetadata) -> NetworkProxyState {
let network = NetworkProxySettings {
enabled: true,
mode: NetworkMode::Full,
..NetworkProxySettings::default()
};
let config = NetworkProxyConfig { network };
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
let reloader = Arc::new(StaticReloader {
state: state.clone(),
});
NetworkProxyState::with_reloader_and_audit_metadata(state, reloader, metadata)
}
fn is_rfc3339_utc_millis(timestamp: &str) -> bool {
let bytes = timestamp.as_bytes();
if bytes.len() != 24 {
return false;
}
bytes[4] == b'-'
&& bytes[7] == b'-'
&& bytes[10] == b'T'
&& bytes[13] == b':'
&& bytes[16] == b':'
&& bytes[19] == b'.'
&& bytes[23] == b'Z'
&& bytes.iter().enumerate().all(|(idx, value)| match idx {
4 | 7 | 10 | 13 | 16 | 19 | 23 => true,
_ => value.is_ascii_digit(),
})
}
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_emits_domain_event_for_decider_allow_override() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
let calls = Arc::new(AtomicUsize::new(0));
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
@@ -251,47 +626,75 @@ mod tests {
host: "example.com".to_string(),
port: 80,
client_addr: None,
method: Some("GET".to_string()),
method: None,
command: None,
exec_policy_hint: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)
.await
.unwrap();
let (decision, events) = capture_events(|| async {
evaluate_host_policy(&state, Some(&decider), &request)
.await
.unwrap()
})
.await;
assert_eq!(decision, NetworkDecision::Allow);
assert_eq!(calls.load(Ordering::SeqCst), 1);
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision audit event");
assert_eq!(event.target, AUDIT_TARGET);
assert!(event.target.starts_with("codex_otel."));
assert_eq!(
event.field("network.policy.scope"),
Some(POLICY_SCOPE_DOMAIN)
);
assert_eq!(event.field("network.policy.decision"), Some("allow"));
assert_eq!(event.field("network.policy.source"), Some("decider"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_NOT_ALLOWED)
);
assert_eq!(event.field("network.transport.protocol"), Some("http"));
assert_eq!(event.field("server.address"), Some("example.com"));
assert_eq!(event.field("server.port"), Some("80"));
assert_eq!(event.field("http.request.method"), Some(DEFAULT_METHOD));
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
assert_eq!(event.field("network.policy.override"), Some("true"));
let timestamp = event
.field("event.timestamp")
.expect("event timestamp should be present");
assert!(is_rfc3339_utc_millis(timestamp));
assert_eq!(
find_event_by_name(&events, LEGACY_DOMAIN_POLICY_DECISION_EVENT_NAME),
None
);
assert_eq!(
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
None
);
}
#[tokio::test]
async fn evaluate_host_policy_skips_decider_for_denied() {
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
denied_domains: vec!["blocked.com".to_string()],
..NetworkProxySettings::default()
});
let calls = Arc::new(AtomicUsize::new(0));
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
let calls = calls.clone();
move |_req| {
calls.fetch_add(1, Ordering::SeqCst);
async { NetworkDecision::Allow }
}
});
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "blocked.com".to_string(),
port: 80,
client_addr: None,
client_addr: Some("127.0.0.1:1234".to_string()),
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)
.await
.unwrap();
let (decision, events) = capture_events(|| async {
evaluate_host_policy(&state, None, &request).await.unwrap()
})
.await;
assert_eq!(
decision,
NetworkDecision::Deny {
@@ -300,25 +703,158 @@ mod tests {
decision: NetworkPolicyDecision::Deny,
}
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision audit event");
assert_eq!(event.field("network.policy.decision"), Some("deny"));
assert_eq!(
event.field("network.policy.source"),
Some("baseline_policy")
);
assert_eq!(event.field("network.policy.reason"), Some(REASON_DENIED));
assert_eq!(event.field("network.policy.override"), Some("false"));
assert_eq!(event.field("http.request.method"), Some("GET"));
assert_eq!(event.field("client.address"), Some("127.0.0.1:1234"));
}
#[tokio::test]
async fn evaluate_host_policy_skips_decider_for_not_allowed_local() {
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_emits_domain_event_for_decider_ask() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
let decider: Arc<dyn NetworkPolicyDecider> =
Arc::new(|_req| async { NetworkDecision::ask(REASON_NOT_ALLOWED) });
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
client_addr: None,
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
});
let (decision, events) = capture_events(|| async {
evaluate_host_policy(&state, Some(&decider), &request)
.await
.unwrap()
})
.await;
assert_eq!(
decision,
NetworkDecision::Deny {
reason: REASON_NOT_ALLOWED.to_string(),
source: NetworkDecisionSource::Decider,
decision: NetworkPolicyDecision::Ask,
}
);
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision audit event");
assert_eq!(event.field("network.policy.decision"), Some("ask"));
assert_eq!(event.field("network.policy.source"), Some("decider"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_NOT_ALLOWED)
);
assert_eq!(event.field("network.policy.override"), Some("false"));
}
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_emits_metadata_fields() {
let metadata = NetworkProxyAuditMetadata {
conversation_id: Some("conversation-1".to_string()),
app_version: Some("1.2.3".to_string()),
user_account_id: Some("acct-1".to_string()),
auth_mode: Some("Chatgpt".to_string()),
originator: Some("codex_cli_rs".to_string()),
user_email: Some("test@example.com".to_string()),
terminal_type: Some("iTerm.app/3.6.5".to_string()),
model: Some("gpt-5.3-codex".to_string()),
slug: Some("gpt-5.3-codex".to_string()),
};
let state = state_with_metadata(metadata);
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "example.com".to_string(),
port: 80,
client_addr: None,
method: Some("GET".to_string()),
command: None,
exec_policy_hint: None,
});
let (_decision, events) = capture_events(|| async {
evaluate_host_policy(&state, None, &request).await.unwrap()
})
.await;
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision audit event");
assert_eq!(event.field("conversation.id"), Some("conversation-1"));
assert_eq!(event.field("app.version"), Some("1.2.3"));
assert_eq!(event.field("auth_mode"), Some("Chatgpt"));
assert_eq!(event.field("originator"), Some("codex_cli_rs"));
assert_eq!(event.field("user.account_id"), Some("acct-1"));
assert_eq!(event.field("user.email"), Some("test@example.com"));
assert_eq!(event.field("terminal.type"), Some("iTerm.app/3.6.5"));
assert_eq!(event.field("model"), Some("gpt-5.3-codex"));
assert_eq!(event.field("slug"), Some("gpt-5.3-codex"));
}
#[tokio::test(flavor = "current_thread")]
async fn emit_block_decision_audit_event_emits_non_domain_event() {
let state = network_proxy_state_for_policy(NetworkProxySettings::default());
let (_, events) = capture_events(|| async {
emit_block_decision_audit_event(
&state,
BlockDecisionAuditEventArgs {
source: NetworkDecisionSource::ModeGuard,
reason: REASON_METHOD_NOT_ALLOWED,
protocol: NetworkProtocol::Http,
server_address: "unix-socket",
server_port: 0,
method: Some("POST"),
client_addr: None,
},
);
})
.await;
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision audit event");
assert_eq!(event.target, AUDIT_TARGET);
assert_eq!(
event.field("network.policy.scope"),
Some(POLICY_SCOPE_NON_DOMAIN)
);
assert_eq!(
event.field("network.policy.decision"),
Some(POLICY_DECISION_DENY)
);
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_METHOD_NOT_ALLOWED)
);
assert_eq!(event.field("network.transport.protocol"), Some("http"));
assert_eq!(event.field("server.address"), Some("unix-socket"));
assert_eq!(event.field("server.port"), Some("0"));
assert_eq!(event.field("http.request.method"), Some("POST"));
assert_eq!(event.field("client.address"), Some(DEFAULT_CLIENT_ADDRESS));
assert_eq!(event.field("network.policy.override"), Some("false"));
assert_eq!(
find_event_by_name(&events, LEGACY_BLOCK_DECISION_EVENT_NAME),
None
);
}
#[tokio::test(flavor = "current_thread")]
async fn evaluate_host_policy_still_denies_not_allowed_local_without_decider_override() {
let state = network_proxy_state_for_policy(NetworkProxySettings {
allowed_domains: vec!["example.com".to_string()],
allow_local_binding: false,
..NetworkProxySettings::default()
});
let calls = Arc::new(AtomicUsize::new(0));
let decider: Arc<dyn NetworkPolicyDecider> = Arc::new({
let calls = calls.clone();
move |_req| {
calls.fetch_add(1, Ordering::SeqCst);
async { NetworkDecision::Allow }
}
});
let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs {
protocol: NetworkProtocol::Http,
host: "127.0.0.1".to_string(),
@@ -329,9 +865,7 @@ mod tests {
exec_policy_hint: None,
});
let decision = evaluate_host_policy(&state, Some(&decider), &request)
.await
.unwrap();
let decision = evaluate_host_policy(&state, None, &request).await.unwrap();
assert_eq!(
decision,
NetworkDecision::Deny {
@@ -340,7 +874,6 @@ mod tests {
decision: NetworkPolicyDecision::Deny,
}
);
assert_eq!(calls.load(Ordering::SeqCst), 0);
}
#[test]
+1
View File
@@ -5,3 +5,4 @@ pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed";
pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local";
pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied";
pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled";
pub(crate) const REASON_UNIX_SOCKET_UNSUPPORTED: &str = "unix_socket_unsupported";
+52 -1
View File
@@ -38,6 +38,19 @@ const MAX_BLOCKED_EVENTS: usize = 200;
const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
const NETWORK_POLICY_VIOLATION_PREFIX: &str = "CODEX_NETWORK_POLICY_VIOLATION";
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NetworkProxyAuditMetadata {
pub conversation_id: Option<String>,
pub app_version: Option<String>,
pub user_account_id: Option<String>,
pub auth_mode: Option<String>,
pub originator: Option<String>,
pub user_email: Option<String>,
pub terminal_type: Option<String>,
pub model: Option<String>,
pub slug: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HostBlockReason {
Denied,
@@ -187,6 +200,7 @@ pub struct NetworkProxyState {
state: Arc<RwLock<ConfigState>>,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Arc<RwLock<Option<Arc<dyn BlockedRequestObserver>>>>,
audit_metadata: NetworkProxyAuditMetadata,
}
impl std::fmt::Debug for NetworkProxyState {
@@ -203,24 +217,57 @@ impl Clone for NetworkProxyState {
state: self.state.clone(),
reloader: self.reloader.clone(),
blocked_request_observer: self.blocked_request_observer.clone(),
audit_metadata: self.audit_metadata.clone(),
}
}
}
impl NetworkProxyState {
pub fn with_reloader(state: ConfigState, reloader: Arc<dyn ConfigReloader>) -> Self {
Self::with_reloader_and_blocked_observer(state, reloader, None)
Self::with_reloader_and_audit_metadata(
state,
reloader,
NetworkProxyAuditMetadata::default(),
)
}
pub fn with_reloader_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
NetworkProxyAuditMetadata::default(),
blocked_request_observer,
)
}
pub fn with_reloader_and_audit_metadata(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
) -> Self {
Self::with_reloader_and_audit_metadata_and_blocked_observer(
state,
reloader,
audit_metadata,
None,
)
}
pub fn with_reloader_and_audit_metadata_and_blocked_observer(
state: ConfigState,
reloader: Arc<dyn ConfigReloader>,
audit_metadata: NetworkProxyAuditMetadata,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
) -> Self {
Self {
state: Arc::new(RwLock::new(state)),
reloader,
blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)),
audit_metadata,
}
}
@@ -232,6 +279,10 @@ impl NetworkProxyState {
*observer = blocked_request_observer;
}
pub fn audit_metadata(&self) -> &NetworkProxyAuditMetadata {
&self.audit_metadata
}
pub async fn current_cfg(&self) -> Result<NetworkProxyConfig> {
// Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to
// `config.toml` (including Codex-managed writes) take effect without a restart.
+188
View File
@@ -1,4 +1,5 @@
use crate::config::NetworkMode;
use crate::network_policy::BlockDecisionAuditEventArgs;
use crate::network_policy::NetworkDecision;
use crate::network_policy::NetworkDecisionSource;
use crate::network_policy::NetworkPolicyDecider;
@@ -6,6 +7,7 @@ use crate::network_policy::NetworkPolicyDecision;
use crate::network_policy::NetworkPolicyRequest;
use crate::network_policy::NetworkPolicyRequestArgs;
use crate::network_policy::NetworkProtocol;
use crate::network_policy::emit_block_decision_audit_event;
use crate::network_policy::evaluate_host_policy;
use crate::policy::normalize_host;
use crate::reasons::REASON_METHOD_NOT_ALLOWED;
@@ -152,6 +154,15 @@ async fn handle_socks5_tcp(
match app_state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ProxyState,
REASON_PROXY_DISABLED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -185,6 +196,15 @@ async fn handle_socks5_tcp(
match app_state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_socks_block_decision_audit_event(
&app_state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Tcp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -298,6 +318,15 @@ async fn inspect_socks5_udp(
match state.enabled().await {
Ok(true) => {}
Ok(false) => {
emit_socks_block_decision_audit_event(
&state,
NetworkDecisionSource::ProxyState,
REASON_PROXY_DISABLED,
NetworkProtocol::Socks5Udp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_PROXY_DISABLED,
@@ -331,6 +360,15 @@ async fn inspect_socks5_udp(
match state.network_mode().await {
Ok(NetworkMode::Limited) => {
emit_socks_block_decision_audit_event(
&state,
NetworkDecisionSource::ModeGuard,
REASON_METHOD_NOT_ALLOWED,
NetworkProtocol::Socks5Udp,
host.as_str(),
port,
client.as_deref(),
);
let details = PolicyDecisionDetails {
decision: NetworkPolicyDecision::Deny,
reason: REASON_METHOD_NOT_ALLOWED,
@@ -413,9 +451,159 @@ async fn inspect_socks5_udp(
}
}
fn emit_socks_block_decision_audit_event(
state: &NetworkProxyState,
source: NetworkDecisionSource,
reason: &str,
protocol: NetworkProtocol,
host: &str,
port: u16,
client_addr: Option<&str>,
) {
emit_block_decision_audit_event(
state,
BlockDecisionAuditEventArgs {
source,
reason,
protocol,
server_address: host,
server_port: port,
method: None,
client_addr,
},
);
}
fn policy_denied_error(reason: &str, details: &PolicyDecisionDetails<'_>) -> io::Error {
io::Error::new(
io::ErrorKind::PermissionDenied,
blocked_message_with_policy(reason, details),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkMode;
use crate::config::NetworkProxyConfig;
use crate::config::NetworkProxySettings;
use crate::network_policy::test_support::POLICY_DECISION_EVENT_NAME;
use crate::network_policy::test_support::capture_events;
use crate::network_policy::test_support::find_event_by_name;
use crate::runtime::ConfigReloader;
use crate::runtime::ConfigState;
use crate::state::NetworkProxyConstraints;
use crate::state::build_config_state;
use async_trait::async_trait;
use pretty_assertions::assert_eq;
use rama_core::extensions::Extensions;
use rama_core::extensions::ExtensionsMut;
use rama_net::address::HostWithPort;
use rama_net::address::SocketAddress;
use rama_socks5::server::udp::RelayDirection;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::sync::Arc;
#[derive(Clone)]
struct StaticReloader {
state: ConfigState,
}
#[async_trait]
impl ConfigReloader for StaticReloader {
async fn maybe_reload(&self) -> anyhow::Result<Option<ConfigState>> {
Ok(None)
}
async fn reload_now(&self) -> anyhow::Result<ConfigState> {
Ok(self.state.clone())
}
fn source_label(&self) -> String {
"static test reloader".to_string()
}
}
fn state_for_settings(network: NetworkProxySettings) -> Arc<NetworkProxyState> {
let config = NetworkProxyConfig { network };
let state = build_config_state(config, NetworkProxyConstraints::default()).unwrap();
let reloader = Arc::new(StaticReloader {
state: state.clone(),
});
Arc::new(NetworkProxyState::with_reloader(state, reloader))
}
#[tokio::test(flavor = "current_thread")]
async fn handle_socks5_tcp_emits_block_decision_for_proxy_disabled() {
let state = state_for_settings(NetworkProxySettings {
enabled: false,
mode: NetworkMode::Full,
..NetworkProxySettings::default()
});
let mut request =
TcpRequest::new(HostWithPort::try_from("example.com:443").expect("valid authority"));
request.extensions_mut().insert(state.clone());
let (result, events) = capture_events(|| async {
handle_socks5_tcp(request, TcpConnector::default(), None).await
})
.await;
assert!(result.is_err(), "proxy-disabled request should be denied");
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
assert_eq!(event.field("network.policy.decision"), Some("deny"));
assert_eq!(event.field("network.policy.source"), Some("proxy_state"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_PROXY_DISABLED)
);
assert_eq!(
event.field("network.transport.protocol"),
Some("socks5_tcp")
);
assert_eq!(event.field("server.address"), Some("example.com"));
assert_eq!(event.field("server.port"), Some("443"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
#[tokio::test(flavor = "current_thread")]
async fn inspect_socks5_udp_emits_block_decision_for_mode_guard_deny() {
let state = state_for_settings(NetworkProxySettings {
enabled: true,
mode: NetworkMode::Limited,
..NetworkProxySettings::default()
});
let request = RelayRequest {
direction: RelayDirection::South,
server_address: SocketAddress::new(IpAddr::V4(Ipv4Addr::new(93, 184, 216, 34)), 53),
payload: Default::default(),
extensions: Extensions::new(),
};
let (result, events) =
capture_events(|| async { inspect_socks5_udp(request, state, None).await }).await;
assert!(result.is_err(), "limited-mode UDP request should be denied");
let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME)
.expect("expected policy decision event");
assert_eq!(event.field("network.policy.scope"), Some("non_domain"));
assert_eq!(event.field("network.policy.decision"), Some("deny"));
assert_eq!(event.field("network.policy.source"), Some("mode_guard"));
assert_eq!(
event.field("network.policy.reason"),
Some(REASON_METHOD_NOT_ALLOWED)
);
assert_eq!(
event.field("network.transport.protocol"),
Some("socks5_udp")
);
assert_eq!(event.field("server.address"), Some("93.184.216.34"));
assert_eq!(event.field("server.port"), Some("53"));
assert_eq!(event.field("http.request.method"), Some("none"));
assert_eq!(event.field("client.address"), Some("unknown"));
}
}
+1
View File
@@ -10,6 +10,7 @@ use std::sync::Arc;
pub use crate::runtime::BlockedRequest;
pub use crate::runtime::BlockedRequestArgs;
pub use crate::runtime::NetworkProxyAuditMetadata;
pub use crate::runtime::NetworkProxyState;
#[cfg(test)]
pub(crate) use crate::runtime::network_proxy_state_for_policy;
+1
View File
@@ -26,6 +26,7 @@ codex-utils-string = { workspace = true }
codex-api = { workspace = true }
codex-protocol = { workspace = true }
eventsource-stream = { workspace = true }
gethostname = { workspace = true }
opentelemetry = { workspace = true, features = ["logs", "metrics", "trace"] }
opentelemetry-appender-tracing = { workspace = true }
opentelemetry-otlp = { workspace = true, features = [
+12 -1
View File
@@ -45,6 +45,7 @@ pub struct OtelEventMetadata {
pub(crate) account_id: Option<String>,
pub(crate) account_email: Option<String>,
pub(crate) originator: String,
pub(crate) service_name: Option<String>,
pub(crate) session_source: String,
pub(crate) model: String,
pub(crate) slug: String,
@@ -67,6 +68,11 @@ impl OtelManager {
self
}
pub fn with_metrics_service_name(mut self, service_name: &str) -> Self {
self.metadata.service_name = Some(sanitize_metric_tag_value(service_name));
self
}
pub fn with_metrics(mut self, metrics: MetricsClient) -> Self {
self.metrics = Some(metrics);
self.metrics_use_metadata_tags = true;
@@ -197,7 +203,7 @@ impl OtelManager {
if !self.metrics_use_metadata_tags {
return Ok(Vec::new());
}
let mut tags = Vec::with_capacity(6);
let mut tags = Vec::with_capacity(7);
Self::push_metadata_tag(&mut tags, "auth_mode", self.metadata.auth_mode.as_deref())?;
Self::push_metadata_tag(
&mut tags,
@@ -209,6 +215,11 @@ impl OtelManager {
"originator",
Some(self.metadata.originator.as_str()),
)?;
Self::push_metadata_tag(
&mut tags,
"service_name",
self.metadata.service_name.as_deref(),
)?;
Self::push_metadata_tag(&mut tags, "model", Some(self.metadata.model.as_str()))?;
Self::push_metadata_tag(&mut tags, "app.version", Some(self.metadata.app_version))?;
Ok(tags)
+74 -7
View File
@@ -3,6 +3,7 @@ use crate::config::OtelHttpProtocol;
use crate::config::OtelSettings;
use crate::metrics::MetricsClient;
use crate::metrics::MetricsConfig;
use gethostname::gethostname;
use opentelemetry::Context;
use opentelemetry::KeyValue;
use opentelemetry::context::ContextGuard;
@@ -40,6 +41,7 @@ use tracing_subscriber::Layer;
use tracing_subscriber::registry::LookupSpan;
const ENV_ATTRIBUTE: &str = "env";
const HOST_NAME_ATTRIBUTE: &str = "host.name";
const TRACEPARENT_ENV_VAR: &str = "TRACEPARENT";
const TRACESTATE_ENV_VAR: &str = "TRACESTATE";
static TRACEPARENT_CONTEXT: OnceLock<Option<Context>> = OnceLock::new();
@@ -223,16 +225,37 @@ fn extract_traceparent_context(traceparent: String, tracestate: Option<String>)
fn make_resource(settings: &OtelSettings) -> Resource {
Resource::builder()
.with_service_name(settings.service_name.clone())
.with_attributes(vec![
KeyValue::new(
semconv::attribute::SERVICE_VERSION,
settings.service_version.clone(),
),
KeyValue::new(ENV_ATTRIBUTE, settings.environment.clone()),
])
.with_attributes(resource_attributes(
settings,
detected_host_name().as_deref(),
))
.build()
}
fn resource_attributes(settings: &OtelSettings, host_name: Option<&str>) -> Vec<KeyValue> {
let mut attributes = vec![
KeyValue::new(
semconv::attribute::SERVICE_VERSION,
settings.service_version.clone(),
),
KeyValue::new(ENV_ATTRIBUTE, settings.environment.clone()),
];
if let Some(host_name) = host_name.and_then(normalize_host_name) {
attributes.push(KeyValue::new(HOST_NAME_ATTRIBUTE, host_name));
}
attributes
}
fn detected_host_name() -> Option<String> {
let host_name = gethostname();
normalize_host_name(host_name.to_string_lossy().as_ref())
}
fn normalize_host_name(host_name: &str) -> Option<String> {
let host_name = host_name.trim();
(!host_name.is_empty()).then(|| host_name.to_owned())
}
fn build_logger(
resource: &Resource,
exporter: &OtelExporter,
@@ -377,6 +400,8 @@ mod tests {
use opentelemetry::trace::SpanId;
use opentelemetry::trace::TraceContextExt;
use opentelemetry::trace::TraceId;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn parses_valid_traceparent() {
@@ -398,4 +423,46 @@ mod tests {
fn invalid_traceparent_returns_none() {
assert!(extract_traceparent_context("not-a-traceparent".to_string(), None).is_none());
}
#[test]
fn resource_attributes_include_host_name_when_present() {
let attrs = resource_attributes(&test_otel_settings(), Some("opentelemetry-test"));
let host_name = attrs
.iter()
.find(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
.map(|kv| kv.value.as_str().to_string());
assert_eq!(host_name, Some("opentelemetry-test".to_string()));
}
#[test]
fn resource_attributes_omit_host_name_when_missing_or_empty() {
let missing = resource_attributes(&test_otel_settings(), None);
let empty = resource_attributes(&test_otel_settings(), Some(" "));
assert!(
!missing
.iter()
.any(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
);
assert!(
!empty
.iter()
.any(|kv| kv.key.as_str() == HOST_NAME_ATTRIBUTE)
);
}
fn test_otel_settings() -> OtelSettings {
OtelSettings {
environment: "test".to_string(),
service_name: "codex-test".to_string(),
service_version: "0.0.0".to_string(),
codex_home: PathBuf::from("."),
exporter: OtelExporter::None,
trace_exporter: OtelExporter::None,
metrics_exporter: OtelExporter::None,
runtime_metrics: false,
}
}
}
+1
View File
@@ -79,6 +79,7 @@ impl OtelManager {
account_id,
account_email,
originator: sanitize_metric_tag_value(originator.as_str()),
service_name: None,
session_source: session_source.to_string(),
model: model.to_owned(),
slug: slug.to_owned(),
@@ -109,3 +109,47 @@ fn manager_allows_disabling_metadata_tags() -> Result<()> {
Ok(())
}
#[test]
fn manager_attaches_optional_service_name_tag() -> Result<()> {
let (metrics, exporter) = build_metrics_with_defaults(&[])?;
let manager = OtelManager::new(
ThreadId::new(),
"gpt-5.1",
"gpt-5.1",
None,
None,
None,
"test_originator".to_string(),
false,
"tty".to_string(),
SessionSource::Cli,
)
.with_metrics_service_name("my_app_server_client")
.with_metrics(metrics);
manager.counter("codex.session_started", 1, &[]);
manager.shutdown_metrics()?;
let resource_metrics = latest_metrics(&exporter);
let metric =
find_metric(&resource_metrics, "codex.session_started").expect("counter metric missing");
let attrs = match metric.data() {
AggregatedMetrics::U64(data) => match data {
MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
attributes_to_map(points[0].attributes())
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
};
assert_eq!(
attrs.get("service_name"),
Some(&"my_app_server_client".to_string())
);
Ok(())
}
@@ -0,0 +1,2 @@
ALTER TABLE stage1_outputs ADD COLUMN usage_count INTEGER;
ALTER TABLE stage1_outputs ADD COLUMN last_usage INTEGER;
@@ -0,0 +1,2 @@
ALTER TABLE stage1_outputs
ADD COLUMN selected_for_phase2 INTEGER NOT NULL DEFAULT 0;
+109
View File
@@ -302,6 +302,7 @@ ORDER BY position ASC
}
/// List threads using the underlying database.
#[allow(clippy::too_many_arguments)]
pub async fn list_threads(
&self,
page_size: usize,
@@ -310,6 +311,7 @@ ORDER BY position ASC
allowed_sources: &[String],
model_providers: Option<&[String]>,
archived_only: bool,
search_term: Option<&str>,
) -> anyhow::Result<crate::ThreadsPage> {
let limit = page_size.saturating_add(1);
@@ -345,6 +347,7 @@ FROM threads
model_providers,
anchor,
sort_key,
search_term,
);
push_thread_order_and_limit(&mut builder, sort_key, limit);
@@ -682,6 +685,7 @@ WHERE id IN (
model_providers,
anchor,
sort_key,
None,
);
push_thread_order_and_limit(&mut builder, sort_key, limit);
@@ -1654,6 +1658,7 @@ fn push_thread_filters<'a>(
model_providers: Option<&'a [String]>,
anchor: Option<&crate::Anchor>,
sort_key: SortKey,
search_term: Option<&'a str>,
) {
builder.push(" WHERE 1 = 1");
if archived_only {
@@ -1680,6 +1685,11 @@ fn push_thread_filters<'a>(
}
separated.push_unseparated(")");
}
if let Some(search_term) = search_term {
builder.push(" AND instr(title, ");
builder.push_bind(search_term);
builder.push(") > 0");
}
if let Some(anchor) = anchor {
let anchor_ts = datetime_to_epoch_seconds(anchor.ts);
let column = match sort_key {
@@ -3114,6 +3124,105 @@ VALUES (?, ?, ?, ?, ?)
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn record_stage1_output_usage_updates_usage_metadata() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let thread_a = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id a");
let thread_b = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("thread id b");
let missing = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("missing id");
let owner = ThreadId::from_string(&Uuid::new_v4().to_string()).expect("owner id");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_a,
codex_home.join("workspace-a"),
))
.await
.expect("upsert thread a");
runtime
.upsert_thread(&test_thread_metadata(
&codex_home,
thread_b,
codex_home.join("workspace-b"),
))
.await
.expect("upsert thread b");
let claim_a = runtime
.try_claim_stage1_job(thread_a, owner, 100, 3600, 64)
.await
.expect("claim stage1 a");
let token_a = match claim_a {
Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
other => panic!("unexpected stage1 claim outcome for a: {other:?}"),
};
assert!(
runtime
.mark_stage1_job_succeeded(thread_a, token_a.as_str(), 100, "raw a", "sum a", None)
.await
.expect("mark stage1 succeeded a")
);
let claim_b = runtime
.try_claim_stage1_job(thread_b, owner, 101, 3600, 64)
.await
.expect("claim stage1 b");
let token_b = match claim_b {
Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
other => panic!("unexpected stage1 claim outcome for b: {other:?}"),
};
assert!(
runtime
.mark_stage1_job_succeeded(thread_b, token_b.as_str(), 101, "raw b", "sum b", None)
.await
.expect("mark stage1 succeeded b")
);
let updated_rows = runtime
.record_stage1_output_usage(&[thread_a, thread_a, thread_b, missing])
.await
.expect("record stage1 output usage");
assert_eq!(updated_rows, 3);
let row_a =
sqlx::query("SELECT usage_count, last_usage FROM stage1_outputs WHERE thread_id = ?")
.bind(thread_a.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("load stage1 usage row a");
let row_b =
sqlx::query("SELECT usage_count, last_usage FROM stage1_outputs WHERE thread_id = ?")
.bind(thread_b.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("load stage1 usage row b");
assert_eq!(
row_a
.try_get::<i64, _>("usage_count")
.expect("usage_count a"),
2
);
assert_eq!(
row_b
.try_get::<i64, _>("usage_count")
.expect("usage_count b"),
1
);
let last_usage_a = row_a.try_get::<i64, _>("last_usage").expect("last_usage a");
let last_usage_b = row_b.try_get::<i64, _>("last_usage").expect("last_usage b");
assert_eq!(last_usage_a, last_usage_b);
assert!(last_usage_a > 0);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn mark_stage1_job_succeeded_enqueues_global_consolidation() {
let codex_home = unique_temp_dir();
+38
View File
@@ -49,6 +49,43 @@ WHERE kind = ? OR kind = ?
Ok(())
}
/// Record usage for cited stage-1 outputs.
///
/// Each thread id increments `usage_count` by one and sets `last_usage` to
/// the current Unix timestamp. Missing rows are ignored.
pub async fn record_stage1_output_usage(
&self,
thread_ids: &[ThreadId],
) -> anyhow::Result<usize> {
if thread_ids.is_empty() {
return Ok(0);
}
let now = Utc::now().timestamp();
let mut tx = self.pool.begin().await?;
let mut updated_rows = 0;
for thread_id in thread_ids {
updated_rows += sqlx::query(
r#"
UPDATE stage1_outputs
SET
usage_count = COALESCE(usage_count, 0) + 1,
last_usage = ?
WHERE thread_id = ?
"#,
)
.bind(now)
.bind(thread_id.to_string())
.execute(&mut *tx)
.await?
.rows_affected() as usize;
}
tx.commit().await?;
Ok(updated_rows)
}
/// Selects and claims stage-1 startup jobs for stale threads.
///
/// Query behavior:
@@ -129,6 +166,7 @@ LEFT JOIN jobs
None,
None,
SortKey::UpdatedAt,
None,
);
builder
.push(" AND id != ")
+7
View File
@@ -0,0 +1,7 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "test-macros",
crate_name = "codex_test_macros",
proc_macro = True,
)
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "codex-test-macros"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
proc-macro = true
[dependencies]
proc-macro2 = "1"
quote = "1"
syn = { version = "2", features = ["full"] }
[lints]
workspace = true
+155
View File
@@ -0,0 +1,155 @@
use proc_macro::TokenStream;
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::Attribute;
use syn::ItemFn;
use syn::parse::Nothing;
use syn::parse_macro_input;
use syn::parse_quote;
const LARGE_STACK_TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024;
/// Run a test body on a dedicated thread with a larger stack.
///
/// For async tests, this macro creates a Tokio multi-thread runtime with two
/// worker threads and blocks on the original async body inside the large-stack
/// thread.
#[proc_macro_attribute]
pub fn large_stack_test(attr: TokenStream, item: TokenStream) -> TokenStream {
parse_macro_input!(attr as Nothing);
let item = parse_macro_input!(item as ItemFn);
expand_large_stack_test(item).into()
}
fn expand_large_stack_test(mut item: ItemFn) -> TokenStream2 {
let attrs = filtered_attributes(&item.attrs);
item.attrs = attrs;
let is_async = item.sig.asyncness.take().is_some();
let name = &item.sig.ident;
let body = &item.block;
let thread_body = if is_async {
quote! {
{
let runtime = ::tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.unwrap_or_else(|error| {
panic!("failed to build tokio runtime for large-stack test: {error}")
});
runtime.block_on(async move #body)
}
}
} else {
quote! { #body }
};
*item.block = parse_quote!({
let handle = ::std::thread::Builder::new()
.name(::std::string::String::from(::std::stringify!(#name)))
.stack_size(#LARGE_STACK_TEST_STACK_SIZE_BYTES)
.spawn(move || #thread_body)
.unwrap_or_else(|error| {
panic!("failed to spawn large-stack test thread: {error}")
});
match handle.join() {
Ok(result) => result,
Err(payload) => ::std::panic::resume_unwind(payload),
}
});
quote! { #item }
}
fn filtered_attributes(attrs: &[Attribute]) -> Vec<Attribute> {
let mut filtered = Vec::with_capacity(attrs.len() + 1);
let mut has_test_attr = false;
for attr in attrs {
if is_tokio_test_attr(attr) {
continue;
}
if is_test_attr(attr) || is_test_case_attr(attr) {
has_test_attr = true;
}
filtered.push(attr.clone());
}
if !has_test_attr {
filtered.push(parse_quote!(#[test]));
}
filtered
}
fn is_test_attr(attr: &Attribute) -> bool {
attr.path().is_ident("test")
}
fn is_test_case_attr(attr: &Attribute) -> bool {
attr.path().is_ident("test_case")
}
fn is_tokio_test_attr(attr: &Attribute) -> bool {
let mut segments = attr.path().segments.iter();
matches!(
(segments.next(), segments.next(), segments.next()),
(Some(first), Some(second), None) if first.ident == "tokio" && second.ident == "test"
)
}
#[cfg(test)]
mod tests {
use super::expand_large_stack_test;
use syn::ItemFn;
use syn::parse_quote;
fn has_attr(item: &ItemFn, name: &str) -> bool {
item.attrs.iter().any(|attr| attr.path().is_ident(name))
}
#[test]
fn adds_test_attribute_when_missing() {
let item: ItemFn = parse_quote! {
fn sample() {}
};
let expanded_tokens = expand_large_stack_test(item);
let expanded: ItemFn = match syn::parse2(expanded_tokens) {
Ok(expanded) => expanded,
Err(error) => panic!("failed to parse expanded function: {error}"),
};
assert!(has_attr(&expanded, "test"));
let body = quote::quote!(#expanded).to_string();
assert!(body.contains("stack_size"));
}
#[test]
fn removes_tokio_test_and_keeps_test_case() {
let item: ItemFn = parse_quote! {
#[test_case(1)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn sample(value: usize) -> anyhow::Result<()> {
let _ = value;
Ok(())
}
};
let expanded_tokens = expand_large_stack_test(item);
let expanded: ItemFn = match syn::parse2(expanded_tokens) {
Ok(expanded) => expanded,
Err(error) => panic!("failed to parse expanded function: {error}"),
};
assert!(has_attr(&expanded, "test_case"));
assert!(!has_attr(&expanded, "test"));
let body = quote::quote!(#expanded).to_string();
assert!(body.contains("tokio :: runtime :: Builder"));
assert!(!body.contains("tokio :: test"));
}
}
+139
View File
@@ -347,6 +347,15 @@ impl ThreadEventStore {
fn op_can_change_pending_replay_state(op: &Op) -> bool {
PendingInteractiveReplayState::op_can_change_state(op)
}
fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
PendingInteractiveReplayState::event_can_change_pending_thread_approvals(event)
}
fn has_pending_thread_approvals(&self) -> bool {
self.pending_interactive_replay
.has_pending_thread_approvals()
}
}
#[derive(Debug)]
@@ -812,6 +821,7 @@ impl App {
};
self.active_thread_id = Some(thread_id);
self.active_thread_rx = receiver;
self.refresh_pending_thread_approvals().await;
}
async fn store_active_thread_receiver(&mut self) {
@@ -845,6 +855,7 @@ impl App {
self.set_thread_active(active_id, false).await;
}
self.active_thread_rx = None;
self.refresh_pending_thread_approvals().await;
}
async fn note_active_thread_outbound_op(&mut self, op: &Op) {
@@ -861,7 +872,63 @@ impl App {
store.note_outbound_op(op);
}
async fn refresh_pending_thread_approvals(&mut self) {
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
.thread_event_channels
.iter()
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
.collect();
let mut pending_thread_ids = Vec::new();
for (thread_id, store) in channels {
if Some(thread_id) == self.active_thread_id {
continue;
}
let store = store.lock().await;
if store.has_pending_thread_approvals() {
pending_thread_ids.push(thread_id);
}
}
pending_thread_ids.sort_by_key(ThreadId::to_string);
let threads = pending_thread_ids
.into_iter()
.map(|thread_id| {
let is_primary = self.primary_thread_id == Some(thread_id);
let fallback_label = if is_primary {
"Main [default]".to_string()
} else {
let thread_id = thread_id.to_string();
let short_id: String = thread_id.chars().take(8).collect();
format!("Agent ({short_id})")
};
if let Some(entry) = self.agent_picker_threads.get(&thread_id) {
let label = format_agent_picker_item_name(
entry.agent_nickname.as_deref(),
entry.agent_role.as_deref(),
is_primary,
);
if label == "Agent" {
let thread_id = thread_id.to_string();
let short_id: String = thread_id.chars().take(8).collect();
format!("{label} ({short_id})")
} else {
label
}
} else {
fallback_label
}
})
.collect();
self.chat_widget.set_pending_thread_approvals(threads);
}
async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> {
let refresh_pending_thread_approvals =
ThreadEventStore::event_can_change_pending_thread_approvals(&event);
let (sender, store) = {
let channel = self.ensure_thread_channel(thread_id);
(channel.sender.clone(), Arc::clone(&channel.store))
@@ -891,6 +958,9 @@ impl App {
}
}
}
if refresh_pending_thread_approvals {
self.refresh_pending_thread_approvals().await;
}
Ok(())
}
@@ -1069,6 +1139,7 @@ impl App {
);
}
self.drain_active_thread_events(tui).await?;
self.refresh_pending_thread_approvals().await;
Ok(())
}
@@ -1092,6 +1163,7 @@ impl App {
self.active_thread_rx = None;
self.primary_thread_id = None;
self.pending_primary_events.clear();
self.chat_widget.set_pending_thread_approvals(Vec::new());
}
async fn start_fresh_session_with_summary_hint(&mut self, tui: &mut tui::Tui) {
@@ -1869,6 +1941,7 @@ impl App {
let submitted = self.chat_widget.submit_op(op);
if submitted && let Some(op) = replay_state_op.as_ref() {
self.note_active_thread_outbound_op(op).await;
self.refresh_pending_thread_approvals().await;
}
}
AppEvent::DiffResult(text) => {
@@ -2615,6 +2688,9 @@ impl App {
AppEvent::OpenAgentPicker => {
self.open_agent_picker().await;
}
AppEvent::RefreshPendingThreadApprovals => {
self.refresh_pending_thread_approvals().await;
}
AppEvent::SelectAgentThread(thread_id) => {
self.select_agent_thread(tui, thread_id).await?;
}
@@ -2954,6 +3030,7 @@ impl App {
ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event);
let sender = channel.sender.clone();
let store = Arc::clone(&channel.store);
let app_event_tx = self.app_event_tx.clone();
self.thread_event_channels.insert(thread_id, channel);
tokio::spawn(async move {
loop {
@@ -2964,11 +3041,16 @@ impl App {
break;
}
};
let refresh_pending_thread_approvals =
ThreadEventStore::event_can_change_pending_thread_approvals(&event);
let should_send = {
let mut guard = store.lock().await;
guard.push_event(event.clone());
guard.active
};
if refresh_pending_thread_approvals {
app_event_tx.send(AppEvent::RefreshPendingThreadApprovals);
}
if should_send && let Err(err) = sender.send(event).await {
tracing::debug!("external thread {thread_id} channel closed: {err}");
break;
@@ -3451,6 +3533,63 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn refresh_pending_thread_approvals_only_lists_inactive_threads() {
let mut app = make_test_app().await;
let main_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread");
let agent_thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread");
app.primary_thread_id = Some(main_thread_id);
app.active_thread_id = Some(main_thread_id);
app.thread_event_channels
.insert(main_thread_id, ThreadEventChannel::new(1));
let agent_channel = ThreadEventChannel::new(1);
{
let mut store = agent_channel.store.lock().await;
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
parsed_cmd: Vec::new(),
},
),
});
}
app.thread_event_channels
.insert(agent_thread_id, agent_channel);
app.agent_picker_threads.insert(
agent_thread_id,
AgentPickerThreadEntry {
agent_nickname: Some("Robie".to_string()),
agent_role: Some("explorer".to_string()),
is_closed: false,
},
);
app.refresh_pending_thread_approvals().await;
assert_eq!(
app.chat_widget.pending_thread_approvals(),
&["Robie [explorer]".to_string()]
);
app.active_thread_id = Some(agent_thread_id);
app.refresh_pending_thread_approvals().await;
assert!(app.chat_widget.pending_thread_approvals().is_empty());
}
#[test]
fn agent_picker_item_name_snapshot() {
let thread_id =
@@ -44,6 +44,20 @@ pub(super) struct PendingInteractiveReplayState {
}
impl PendingInteractiveReplayState {
pub(super) fn event_can_change_pending_thread_approvals(event: &Event) -> bool {
matches!(
&event.msg,
EventMsg::ExecApprovalRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::PatchApplyBegin(_)
| EventMsg::TurnComplete(_)
| EventMsg::TurnAborted(_)
| EventMsg::ShutdownComplete
)
}
pub(super) fn op_can_change_state(op: &Op) -> bool {
matches!(
op,
@@ -240,6 +254,12 @@ impl PendingInteractiveReplayState {
}
}
pub(super) fn has_pending_thread_approvals(&self) -> bool {
!self.exec_approval_call_ids.is_empty()
|| !self.patch_approval_call_ids.is_empty()
|| !self.elicitation_requests.is_empty()
}
fn clear_request_user_input_turn(&mut self, turn_id: &str) {
if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.remove(turn_id) {
for call_id in call_ids {
@@ -584,4 +604,56 @@ mod tests {
"resolved elicitation prompt should not replay on thread switch"
);
}
#[test]
fn thread_event_store_reports_pending_thread_approvals() {
let mut store = ThreadEventStore::new(8);
assert_eq!(store.has_pending_thread_approvals(), false);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::ExecApprovalRequest(
codex_protocol::protocol::ExecApprovalRequestEvent {
call_id: "call-1".to_string(),
approval_id: None,
turn_id: "turn-1".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
network_approval_context: None,
proposed_execpolicy_amendment: None,
proposed_network_policy_amendments: None,
additional_permissions: None,
parsed_cmd: Vec::new(),
},
),
});
assert_eq!(store.has_pending_thread_approvals(), true);
store.note_outbound_op(&Op::ExecApproval {
id: "call-1".to_string(),
turn_id: Some("turn-1".to_string()),
decision: codex_protocol::protocol::ReviewDecision::Approved,
});
assert_eq!(store.has_pending_thread_approvals(), false);
}
#[test]
fn request_user_input_does_not_count_as_pending_thread_approval() {
let mut store = ThreadEventStore::new(8);
store.push_event(Event {
id: "ev-1".to_string(),
msg: EventMsg::RequestUserInput(
codex_protocol::request_user_input::RequestUserInputEvent {
call_id: "call-1".to_string(),
turn_id: "turn-1".to_string(),
questions: Vec::new(),
},
),
});
assert_eq!(store.has_pending_thread_approvals(), false);
}
}
+3
View File
@@ -51,6 +51,9 @@ pub(crate) enum AppEvent {
/// Switch the active thread to the selected agent.
SelectAgentThread(ThreadId),
/// Recompute the list of inactive threads that still need approval.
RefreshPendingThreadApprovals,
/// Start a new session.
NewSession,
+25 -2
View File
@@ -17,6 +17,7 @@ use std::path::PathBuf;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
@@ -92,6 +93,7 @@ pub(crate) use skills_toggle_view::SkillsToggleView;
pub(crate) use status_line_setup::StatusLineItem;
pub(crate) use status_line_setup::StatusLineSetupView;
mod paste_burst;
mod pending_thread_approvals;
pub mod popup_consts;
mod queued_user_messages;
mod scroll_state;
@@ -171,6 +173,8 @@ pub(crate) struct BottomPane {
unified_exec_footer: UnifiedExecFooter,
/// Queued user messages to show above the composer while a turn is running.
queued_user_messages: QueuedUserMessages,
/// Inactive threads with pending approval requests.
pending_thread_approvals: PendingThreadApprovals,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
}
@@ -219,6 +223,7 @@ impl BottomPane {
status: None,
unified_exec_footer: UnifiedExecFooter::new(),
queued_user_messages: QueuedUserMessages::new(),
pending_thread_approvals: PendingThreadApprovals::new(),
esc_backtrack_hint: false,
animations_enabled,
context_window_percent: None,
@@ -759,6 +764,18 @@ impl BottomPane {
self.request_redraw();
}
/// Update the inactive-thread approval list shown above the composer.
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
if self.pending_thread_approvals.set_threads(threads) {
self.request_redraw();
}
}
#[cfg(test)]
pub(crate) fn pending_thread_approvals(&self) -> &[String] {
self.pending_thread_approvals.threads()
}
/// Update the unified-exec process set and refresh whichever summary surface is active.
///
/// The summary may be displayed inline in the status row or as a dedicated
@@ -980,14 +997,20 @@ impl BottomPane {
if self.status.is_none() && !self.unified_exec_footer.is_empty() {
flex.push(0, RenderableItem::Borrowed(&self.unified_exec_footer));
}
let has_pending_thread_approvals = !self.pending_thread_approvals.is_empty();
let has_queued_messages = !self.queued_user_messages.messages.is_empty();
let has_status_or_footer =
self.status.is_some() || !self.unified_exec_footer.is_empty();
if has_queued_messages && has_status_or_footer {
let has_inline_previews = has_pending_thread_approvals || has_queued_messages;
if has_inline_previews && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
flex.push(1, RenderableItem::Borrowed(&self.pending_thread_approvals));
if has_pending_thread_approvals && has_queued_messages {
flex.push(0, RenderableItem::Owned("".into()));
}
flex.push(1, RenderableItem::Borrowed(&self.queued_user_messages));
if !has_queued_messages && has_status_or_footer {
if !has_inline_previews && has_status_or_footer {
flex.push(0, RenderableItem::Owned("".into()));
}
let mut flex2 = FlexRenderable::new();
@@ -0,0 +1,147 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use crate::render::renderable::Renderable;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;
/// Widget that lists inactive threads with outstanding approval requests.
pub(crate) struct PendingThreadApprovals {
threads: Vec<String>,
}
impl PendingThreadApprovals {
pub(crate) fn new() -> Self {
Self {
threads: Vec::new(),
}
}
pub(crate) fn set_threads(&mut self, threads: Vec<String>) -> bool {
if self.threads == threads {
return false;
}
self.threads = threads;
true
}
pub(crate) fn is_empty(&self) -> bool {
self.threads.is_empty()
}
#[cfg(test)]
pub(crate) fn threads(&self) -> &[String] {
&self.threads
}
fn as_renderable(&self, width: u16) -> Box<dyn Renderable> {
if self.threads.is_empty() || width < 4 {
return Box::new(());
}
let mut lines = Vec::new();
for thread in self.threads.iter().take(3) {
let wrapped = adaptive_wrap_lines(
std::iter::once(Line::from(format!("Approval needed in {thread}"))),
RtOptions::new(width as usize)
.initial_indent(Line::from(vec![" ".into(), "!".red().bold(), " ".into()]))
.subsequent_indent(Line::from(" ")),
);
lines.extend(wrapped);
}
if self.threads.len() > 3 {
lines.push(Line::from(" ...".dim().italic()));
}
lines.push(
Line::from(vec![
" ".into(),
"/agent".cyan().bold(),
" to switch threads".dim(),
])
.dim(),
);
Paragraph::new(lines).into()
}
}
impl Renderable for PendingThreadApprovals {
fn render(&self, area: Rect, buf: &mut Buffer) {
if area.is_empty() {
return;
}
self.as_renderable(area.width).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.as_renderable(width).desired_height(width)
}
}
#[cfg(test)]
mod tests {
use super::*;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
fn snapshot_rows(widget: &PendingThreadApprovals, width: u16) -> String {
let height = widget.desired_height(width);
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
widget.render(Rect::new(0, 0, width, height), &mut buf);
(0..height)
.map(|y| {
(0..width)
.map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn desired_height_empty() {
let widget = PendingThreadApprovals::new();
assert_eq!(widget.desired_height(40), 0);
}
#[test]
fn render_single_thread_snapshot() {
let mut widget = PendingThreadApprovals::new();
widget.set_threads(vec!["Robie [explorer]".to_string()]);
assert_snapshot!(
snapshot_rows(&widget, 40).replace(' ', "."),
@r"
..!.Approval.needed.in.Robie.[explorer].
..../agent.to.switch.threads............"
);
}
#[test]
fn render_multiple_threads_snapshot() {
let mut widget = PendingThreadApprovals::new();
widget.set_threads(vec![
"Main [default]".to_string(),
"Robie [explorer]".to_string(),
"Inspector".to_string(),
"Extra agent".to_string(),
]);
assert_snapshot!(
snapshot_rows(&widget, 44).replace(' ', "."),
@r"
..!.Approval.needed.in.Main.[default].......
..!.Approval.needed.in.Robie.[explorer].....
..!.Approval.needed.in.Inspector............
............................................
..../agent.to.switch.threads................"
);
}
}
+9
View File
@@ -4647,6 +4647,10 @@ impl ChatWidget {
self.bottom_pane.set_queued_user_messages(messages);
}
pub(crate) fn set_pending_thread_approvals(&mut self, threads: Vec<String>) {
self.bottom_pane.set_pending_thread_approvals(threads);
}
pub(crate) fn add_diff_in_progress(&mut self) {
self.request_redraw();
}
@@ -7331,6 +7335,11 @@ impl ChatWidget {
self.bottom_pane.remote_image_urls()
}
#[cfg(test)]
pub(crate) fn pending_thread_approvals(&self) -> &[String] {
self.bottom_pane.pending_thread_approvals()
}
pub(crate) fn show_esc_backtrack_hint(&mut self) {
self.bottom_pane.show_esc_backtrack_hint();
}
+1
View File
@@ -678,6 +678,7 @@ async fn run_ratatui_app(
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
&config.model_provider_id,
None,
)
.await
{
+1
View File
@@ -159,6 +159,7 @@ async fn run_session_picker(
INTERACTIVE_SESSION_SOURCES,
Some(provider_filter.as_slice()),
request.default_provider.as_str(),
None,
)
.await;
let _ = tx.send(BackgroundEvent::PageLoaded {
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "stream-parser",
crate_name = "codex_utils_stream_parser",
)
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "codex-utils-stream-parser"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dev-dependencies]
pretty_assertions = { workspace = true }
+97
View File
@@ -0,0 +1,97 @@
# codex-utils-stream-parser
Small, dependency-free utilities for parsing streamed text incrementally.
**Disclaimer**: This code is pretty complex and Codex did not manage to write it so before updating the code, make
sure to deeply understand it and don't blindly trust Codex on it. Feel free to update the documentation as you
modify the code
## What it provides
- `StreamTextParser`: trait for incremental parsers that consume string chunks
- `InlineHiddenTagParser<T>`: generic parser that hides inline tags and extracts their contents
- `CitationStreamParser`: convenience wrapper for `<oai-mem-citation>...</oai-mem-citation>`
- `strip_citations(...)`: one-shot helper for non-streamed strings
- `Utf8StreamParser<P>`: adapter for raw `&[u8]` streams that may split UTF-8 code points
## Why this exists
Some model outputs arrive as a stream and may contain hidden markup (for example
`<oai-mem-citation>...</oai-mem-citation>`) split across chunk boundaries. Parsing each chunk
independently is incorrect because tags can be split (`<oai-mem-` + `citation>`).
This crate keeps parser state across chunks, returns visible text safe to render
immediately, and extracts hidden payloads separately.
## Example: citation streaming
```rust
use codex_utils_stream_parser::CitationStreamParser;
use codex_utils_stream_parser::StreamTextParser;
let mut parser = CitationStreamParser::new();
let first = parser.push_str("Hello <oai-mem-");
assert_eq!(first.visible_text, "Hello ");
assert!(first.extracted.is_empty());
let second = parser.push_str("citation>doc A</oai-mem-citation> world");
assert_eq!(second.visible_text, " world");
assert_eq!(second.extracted, vec!["doc A".to_string()]);
let tail = parser.finish();
assert!(tail.visible_text.is_empty());
assert!(tail.extracted.is_empty());
```
## Example: raw byte streaming with split UTF-8 code points
```rust
use codex_utils_stream_parser::CitationStreamParser;
use codex_utils_stream_parser::Utf8StreamParser;
# fn demo() -> Result<(), codex_utils_stream_parser::Utf8StreamParserError> {
let mut parser = Utf8StreamParser::new(CitationStreamParser::new());
// "é" split across chunks: 0xC3 + 0xA9
let first = parser.push_bytes(&[b'H', 0xC3])?;
assert_eq!(first.visible_text, "H");
let second = parser.push_bytes(&[0xA9, b'!'])?;
assert_eq!(second.visible_text, "é!");
let tail = parser.finish()?;
assert!(tail.visible_text.is_empty());
# Ok(())
# }
```
## Example: custom hidden tags
```rust
use codex_utils_stream_parser::InlineHiddenTagParser;
use codex_utils_stream_parser::InlineTagSpec;
use codex_utils_stream_parser::StreamTextParser;
#[derive(Clone, Debug, PartialEq, Eq)]
enum Tag {
Secret,
}
let mut parser = InlineHiddenTagParser::new(vec![InlineTagSpec {
tag: Tag::Secret,
open: "<secret>",
close: "</secret>",
}]);
let out = parser.push_str("a<secret>x</secret>b");
assert_eq!(out.visible_text, "ab");
assert_eq!(out.extracted.len(), 1);
assert_eq!(out.extracted[0].content, "x");
```
## Known limitations
- Tags are matched literally and case-sensitively
- No nested tag support
- A stream can return empty objects.
@@ -0,0 +1,130 @@
use crate::CitationStreamParser;
use crate::ProposedPlanParser;
use crate::ProposedPlanSegment;
use crate::StreamTextChunk;
use crate::StreamTextParser;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct AssistantTextChunk {
pub visible_text: String,
pub citations: Vec<String>,
pub plan_segments: Vec<ProposedPlanSegment>,
}
impl AssistantTextChunk {
pub fn is_empty(&self) -> bool {
self.visible_text.is_empty() && self.citations.is_empty() && self.plan_segments.is_empty()
}
}
/// Parses assistant text streaming markup in one pass:
/// - strips `<oai-mem-citation>` tags and extracts citation payloads
/// - in plan mode, also strips `<proposed_plan>` blocks and emits plan segments
#[derive(Debug, Default)]
pub struct AssistantTextStreamParser {
plan_mode: bool,
citations: CitationStreamParser,
plan: ProposedPlanParser,
}
impl AssistantTextStreamParser {
pub fn new(plan_mode: bool) -> Self {
Self {
plan_mode,
..Self::default()
}
}
pub fn push_str(&mut self, chunk: &str) -> AssistantTextChunk {
let citation_chunk = self.citations.push_str(chunk);
let mut out = self.parse_visible_text(citation_chunk.visible_text);
out.citations = citation_chunk.extracted;
out
}
pub fn finish(&mut self) -> AssistantTextChunk {
let citation_chunk = self.citations.finish();
let mut out = self.parse_visible_text(citation_chunk.visible_text);
if self.plan_mode {
let mut tail = self.plan.finish();
if !tail.is_empty() {
out.visible_text.push_str(&tail.visible_text);
out.plan_segments.append(&mut tail.extracted);
}
}
out.citations = citation_chunk.extracted;
out
}
fn parse_visible_text(&mut self, visible_text: String) -> AssistantTextChunk {
if !self.plan_mode {
return AssistantTextChunk {
visible_text,
..AssistantTextChunk::default()
};
}
let plan_chunk: StreamTextChunk<ProposedPlanSegment> = self.plan.push_str(&visible_text);
AssistantTextChunk {
visible_text: plan_chunk.visible_text,
plan_segments: plan_chunk.extracted,
..AssistantTextChunk::default()
}
}
}
#[cfg(test)]
mod tests {
use super::AssistantTextStreamParser;
use crate::ProposedPlanSegment;
use pretty_assertions::assert_eq;
#[test]
fn parses_citations_across_seed_and_delta_boundaries() {
let mut parser = AssistantTextStreamParser::new(false);
let seeded = parser.push_str("hello <oai-mem-citation>doc");
let parsed = parser.push_str("1</oai-mem-citation> world");
let tail = parser.finish();
assert_eq!(seeded.visible_text, "hello ");
assert_eq!(seeded.citations, Vec::<String>::new());
assert_eq!(parsed.visible_text, " world");
assert_eq!(parsed.citations, vec!["doc1".to_string()]);
assert_eq!(tail.visible_text, "");
assert_eq!(tail.citations, Vec::<String>::new());
}
#[test]
fn parses_plan_segments_after_citation_stripping() {
let mut parser = AssistantTextStreamParser::new(true);
let seeded = parser.push_str("Intro\n<proposed");
let parsed = parser.push_str("_plan>\n- step <oai-mem-citation>doc</oai-mem-citation>\n");
let tail = parser.push_str("</proposed_plan>\nOutro");
let finish = parser.finish();
assert_eq!(seeded.visible_text, "Intro\n");
assert_eq!(
seeded.plan_segments,
vec![ProposedPlanSegment::Normal("Intro\n".to_string())]
);
assert_eq!(parsed.visible_text, "");
assert_eq!(parsed.citations, vec!["doc".to_string()]);
assert_eq!(
parsed.plan_segments,
vec![
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step \n".to_string()),
]
);
assert_eq!(tail.visible_text, "Outro");
assert_eq!(
tail.plan_segments,
vec![
ProposedPlanSegment::ProposedPlanEnd,
ProposedPlanSegment::Normal("Outro".to_string()),
]
);
assert!(finish.is_empty());
}
}
@@ -0,0 +1,179 @@
use crate::InlineHiddenTagParser;
use crate::InlineTagSpec;
use crate::StreamTextChunk;
use crate::StreamTextParser;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CitationTag {
Citation,
}
const CITATION_OPEN: &str = "<oai-mem-citation>";
const CITATION_CLOSE: &str = "</oai-mem-citation>";
/// Stream parser for `<oai-mem-citation>...</oai-mem-citation>` tags.
///
/// This is a thin convenience wrapper around [`InlineHiddenTagParser`]. It returns citation bodies
/// as plain strings and omits the citation tags from visible text.
///
/// Matching is literal and non-nested. If EOF is reached before a closing
/// `</oai-mem-citation>`, the parser auto-closes the tag and returns the buffered body as an
/// extracted citation.
#[derive(Debug)]
pub struct CitationStreamParser {
inner: InlineHiddenTagParser<CitationTag>,
}
impl CitationStreamParser {
pub fn new() -> Self {
Self {
inner: InlineHiddenTagParser::new(vec![InlineTagSpec {
tag: CitationTag::Citation,
open: CITATION_OPEN,
close: CITATION_CLOSE,
}]),
}
}
}
impl Default for CitationStreamParser {
fn default() -> Self {
Self::new()
}
}
impl StreamTextParser for CitationStreamParser {
type Extracted = String;
fn push_str(&mut self, chunk: &str) -> StreamTextChunk<Self::Extracted> {
let inner = self.inner.push_str(chunk);
StreamTextChunk {
visible_text: inner.visible_text,
extracted: inner.extracted.into_iter().map(|tag| tag.content).collect(),
}
}
fn finish(&mut self) -> StreamTextChunk<Self::Extracted> {
let inner = self.inner.finish();
StreamTextChunk {
visible_text: inner.visible_text,
extracted: inner.extracted.into_iter().map(|tag| tag.content).collect(),
}
}
}
/// Strip citation tags from a complete string and return `(visible_text, citations)`.
///
/// This uses [`CitationStreamParser`] internally, so it inherits the same semantics:
/// literal, non-nested matching and auto-closing unterminated citations at EOF.
pub fn strip_citations(text: &str) -> (String, Vec<String>) {
let mut parser = CitationStreamParser::new();
let mut out = parser.push_str(text);
let tail = parser.finish();
out.visible_text.push_str(&tail.visible_text);
out.extracted.extend(tail.extracted);
(out.visible_text, out.extracted)
}
#[cfg(test)]
mod tests {
use super::CitationStreamParser;
use super::strip_citations;
use crate::StreamTextChunk;
use crate::StreamTextParser;
use pretty_assertions::assert_eq;
fn collect_chunks<P>(parser: &mut P, chunks: &[&str]) -> StreamTextChunk<P::Extracted>
where
P: StreamTextParser,
{
let mut all = StreamTextChunk::default();
for chunk in chunks {
let next = parser.push_str(chunk);
all.visible_text.push_str(&next.visible_text);
all.extracted.extend(next.extracted);
}
let tail = parser.finish();
all.visible_text.push_str(&tail.visible_text);
all.extracted.extend(tail.extracted);
all
}
#[test]
fn citation_parser_streams_across_chunk_boundaries() {
let mut parser = CitationStreamParser::new();
let out = collect_chunks(
&mut parser,
&[
"Hello <oai-mem-",
"citation>source A</oai-mem-",
"citation> world",
],
);
assert_eq!(out.visible_text, "Hello world");
assert_eq!(out.extracted, vec!["source A".to_string()]);
}
#[test]
fn citation_parser_buffers_partial_open_tag_prefix() {
let mut parser = CitationStreamParser::new();
let first = parser.push_str("abc <oai-mem-");
assert_eq!(first.visible_text, "abc ");
assert_eq!(first.extracted, Vec::<String>::new());
let second = parser.push_str("citation>x</oai-mem-citation>z");
let tail = parser.finish();
assert_eq!(second.visible_text, "z");
assert_eq!(second.extracted, vec!["x".to_string()]);
assert!(tail.is_empty());
}
#[test]
fn citation_parser_auto_closes_unterminated_tag_on_finish() {
let mut parser = CitationStreamParser::new();
let out = collect_chunks(&mut parser, &["x<oai-mem-citation>source"]);
assert_eq!(out.visible_text, "x");
assert_eq!(out.extracted, vec!["source".to_string()]);
}
#[test]
fn citation_parser_preserves_partial_open_tag_at_eof_if_not_a_full_tag() {
let mut parser = CitationStreamParser::new();
let out = collect_chunks(&mut parser, &["hello <oai-mem-"]);
assert_eq!(out.visible_text, "hello <oai-mem-");
assert_eq!(out.extracted, Vec::<String>::new());
}
#[test]
fn strip_citations_collects_all_citations() {
let (visible, citations) = strip_citations(
"a<oai-mem-citation>one</oai-mem-citation>b<oai-mem-citation>two</oai-mem-citation>c",
);
assert_eq!(visible, "abc");
assert_eq!(citations, vec!["one".to_string(), "two".to_string()]);
}
#[test]
fn strip_citations_auto_closes_unterminated_citation_at_eof() {
let (visible, citations) = strip_citations("x<oai-mem-citation>y");
assert_eq!(visible, "x");
assert_eq!(citations, vec!["y".to_string()]);
}
#[test]
fn citation_parser_does_not_support_nested_tags() {
let (visible, citations) = strip_citations(
"a<oai-mem-citation>x<oai-mem-citation>y</oai-mem-citation>z</oai-mem-citation>b",
);
assert_eq!(visible, "az</oai-mem-citation>b");
assert_eq!(citations, vec!["x<oai-mem-citation>y".to_string()]);
}
}
@@ -0,0 +1,323 @@
use crate::StreamTextChunk;
use crate::StreamTextParser;
/// One hidden inline tag extracted by [`InlineHiddenTagParser`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedInlineTag<T> {
pub tag: T,
pub content: String,
}
/// Literal tag specification used by [`InlineHiddenTagParser`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InlineTagSpec<T> {
pub tag: T,
pub open: &'static str,
pub close: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ActiveTag<T> {
tag: T,
close: &'static str,
content: String,
}
/// Generic streaming parser that hides configured inline tags and extracts their contents.
///
/// Example:
/// - input: `hello <oai-mem-citation>doc A</oai-mem-citation> world`
/// - visible output: `hello world`
/// - extracted: `["doc A"]`
///
/// Matching is literal and non-nested. If EOF is reached while a tag is still open, the parser
/// auto-closes it and returns the buffered content as extracted data.
#[derive(Debug)]
pub struct InlineHiddenTagParser<T>
where
T: Clone + Eq,
{
specs: Vec<InlineTagSpec<T>>,
pending: String,
active: Option<ActiveTag<T>>,
}
impl<T> InlineHiddenTagParser<T>
where
T: Clone + Eq,
{
/// Create a parser for one or more hidden inline tags.
pub fn new(specs: Vec<InlineTagSpec<T>>) -> Self {
assert!(
!specs.is_empty(),
"InlineHiddenTagParser requires at least one tag spec"
);
for spec in &specs {
assert!(
!spec.open.is_empty(),
"InlineHiddenTagParser requires non-empty open delimiters"
);
assert!(
!spec.close.is_empty(),
"InlineHiddenTagParser requires non-empty close delimiters"
);
}
Self {
specs,
pending: String::new(),
active: None,
}
}
fn find_next_open(&self) -> Option<(usize, usize)> {
self.specs
.iter()
.enumerate()
.filter_map(|(idx, spec)| {
self.pending
.find(spec.open)
.map(|pos| (pos, spec.open.len(), idx))
})
.min_by(|(pos_a, len_a, idx_a), (pos_b, len_b, idx_b)| {
pos_a
.cmp(pos_b)
.then_with(|| len_b.cmp(len_a))
.then_with(|| idx_a.cmp(idx_b))
})
.map(|(pos, _len, idx)| (pos, idx))
}
fn max_open_prefix_suffix_len(&self) -> usize {
self.specs
.iter()
.map(|spec| longest_suffix_prefix_len(&self.pending, spec.open))
.max()
.map_or(0, std::convert::identity)
}
fn push_visible_prefix(out: &mut StreamTextChunk<ExtractedInlineTag<T>>, pending: &str) {
if !pending.is_empty() {
out.visible_text.push_str(pending);
}
}
fn drain_visible_to_suffix_match(
&mut self,
out: &mut StreamTextChunk<ExtractedInlineTag<T>>,
keep_suffix_len: usize,
) {
let take = self.pending.len().saturating_sub(keep_suffix_len);
if take == 0 {
return;
}
Self::push_visible_prefix(out, &self.pending[..take]);
self.pending.drain(..take);
}
}
impl<T> StreamTextParser for InlineHiddenTagParser<T>
where
T: Clone + Eq,
{
type Extracted = ExtractedInlineTag<T>;
fn push_str(&mut self, chunk: &str) -> StreamTextChunk<Self::Extracted> {
self.pending.push_str(chunk);
let mut out = StreamTextChunk::default();
loop {
if let Some(close) = self.active.as_ref().map(|active| active.close) {
if let Some(close_idx) = self.pending.find(close) {
let Some(mut active) = self.active.take() else {
continue;
};
active.content.push_str(&self.pending[..close_idx]);
out.extracted.push(ExtractedInlineTag {
tag: active.tag,
content: active.content,
});
let close_len = close.len();
self.pending.drain(..close_idx + close_len);
continue;
}
let keep = longest_suffix_prefix_len(&self.pending, close);
let take = self.pending.len().saturating_sub(keep);
if take > 0 {
if let Some(active) = self.active.as_mut() {
active.content.push_str(&self.pending[..take]);
}
self.pending.drain(..take);
}
break;
}
if let Some((open_idx, spec_idx)) = self.find_next_open() {
Self::push_visible_prefix(&mut out, &self.pending[..open_idx]);
let spec = &self.specs[spec_idx];
let open_len = spec.open.len();
self.pending.drain(..open_idx + open_len);
self.active = Some(ActiveTag {
tag: spec.tag.clone(),
close: spec.close,
content: String::new(),
});
continue;
}
let keep = self.max_open_prefix_suffix_len();
self.drain_visible_to_suffix_match(&mut out, keep);
break;
}
out
}
fn finish(&mut self) -> StreamTextChunk<Self::Extracted> {
let mut out = StreamTextChunk::default();
if let Some(mut active) = self.active.take() {
if !self.pending.is_empty() {
active.content.push_str(&self.pending);
self.pending.clear();
}
out.extracted.push(ExtractedInlineTag {
tag: active.tag,
content: active.content,
});
return out;
}
if !self.pending.is_empty() {
out.visible_text.push_str(&self.pending);
self.pending.clear();
}
out
}
}
fn longest_suffix_prefix_len(s: &str, needle: &str) -> usize {
let max = s.len().min(needle.len().saturating_sub(1));
for k in (1..=max).rev() {
if needle.is_char_boundary(k) && s.ends_with(&needle[..k]) {
return k;
}
}
0
}
#[cfg(test)]
mod tests {
use super::InlineHiddenTagParser;
use super::InlineTagSpec;
use crate::StreamTextChunk;
use crate::StreamTextParser;
use pretty_assertions::assert_eq;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tag {
A,
B,
}
fn collect_chunks<P>(parser: &mut P, chunks: &[&str]) -> StreamTextChunk<P::Extracted>
where
P: StreamTextParser,
{
let mut all = StreamTextChunk::default();
for chunk in chunks {
let next = parser.push_str(chunk);
all.visible_text.push_str(&next.visible_text);
all.extracted.extend(next.extracted);
}
let tail = parser.finish();
all.visible_text.push_str(&tail.visible_text);
all.extracted.extend(tail.extracted);
all
}
#[test]
fn generic_inline_parser_supports_multiple_tag_types() {
let mut parser = InlineHiddenTagParser::new(vec![
InlineTagSpec {
tag: Tag::A,
open: "<a>",
close: "</a>",
},
InlineTagSpec {
tag: Tag::B,
open: "<b>",
close: "</b>",
},
]);
let out = collect_chunks(&mut parser, &["1<a>x</a>2<b>y</b>3"]);
assert_eq!(out.visible_text, "123");
assert_eq!(out.extracted.len(), 2);
assert_eq!(out.extracted[0].tag, Tag::A);
assert_eq!(out.extracted[0].content, "x");
assert_eq!(out.extracted[1].tag, Tag::B);
assert_eq!(out.extracted[1].content, "y");
}
#[test]
fn generic_inline_parser_supports_non_ascii_tag_delimiters() {
let mut parser = InlineHiddenTagParser::new(vec![InlineTagSpec {
tag: Tag::A,
open: "<é>",
close: "</é>",
}]);
let out = collect_chunks(&mut parser, &["a<", "é>中</", "é>b"]);
assert_eq!(out.visible_text, "ab");
assert_eq!(out.extracted.len(), 1);
assert_eq!(out.extracted[0].tag, Tag::A);
assert_eq!(out.extracted[0].content, "中");
}
#[test]
fn generic_inline_parser_prefers_longest_opener_at_same_offset() {
let mut parser = InlineHiddenTagParser::new(vec![
InlineTagSpec {
tag: Tag::A,
open: "<a>",
close: "</a>",
},
InlineTagSpec {
tag: Tag::B,
open: "<ab>",
close: "</ab>",
},
]);
let out = collect_chunks(&mut parser, &["x<ab>y</ab>z"]);
assert_eq!(out.visible_text, "xz");
assert_eq!(out.extracted.len(), 1);
assert_eq!(out.extracted[0].tag, Tag::B);
assert_eq!(out.extracted[0].content, "y");
}
#[test]
#[should_panic(expected = "non-empty open delimiters")]
fn generic_inline_parser_rejects_empty_open_delimiter() {
let _ = InlineHiddenTagParser::new(vec![InlineTagSpec {
tag: Tag::A,
open: "",
close: "</a>",
}]);
}
#[test]
#[should_panic(expected = "non-empty close delimiters")]
fn generic_inline_parser_rejects_empty_close_delimiter() {
let _ = InlineHiddenTagParser::new(vec![InlineTagSpec {
tag: Tag::A,
open: "<a>",
close: "",
}]);
}
}
+23
View File
@@ -0,0 +1,23 @@
mod assistant_text;
mod citation;
mod inline_hidden_tag;
mod proposed_plan;
mod stream_text;
mod tagged_line_parser;
mod utf8_stream;
pub use assistant_text::AssistantTextChunk;
pub use assistant_text::AssistantTextStreamParser;
pub use citation::CitationStreamParser;
pub use citation::strip_citations;
pub use inline_hidden_tag::ExtractedInlineTag;
pub use inline_hidden_tag::InlineHiddenTagParser;
pub use inline_hidden_tag::InlineTagSpec;
pub use proposed_plan::ProposedPlanParser;
pub use proposed_plan::ProposedPlanSegment;
pub use proposed_plan::extract_proposed_plan_text;
pub use proposed_plan::strip_proposed_plan_blocks;
pub use stream_text::StreamTextChunk;
pub use stream_text::StreamTextParser;
pub use utf8_stream::Utf8StreamParser;
pub use utf8_stream::Utf8StreamParserError;
@@ -0,0 +1,212 @@
use crate::StreamTextChunk;
use crate::StreamTextParser;
use crate::tagged_line_parser::TagSpec;
use crate::tagged_line_parser::TaggedLineParser;
use crate::tagged_line_parser::TaggedLineSegment;
const OPEN_TAG: &str = "<proposed_plan>";
const CLOSE_TAG: &str = "</proposed_plan>";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PlanTag {
ProposedPlan,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposedPlanSegment {
Normal(String),
ProposedPlanStart,
ProposedPlanDelta(String),
ProposedPlanEnd,
}
/// Parser for `<proposed_plan>` blocks emitted in plan mode.
///
/// Implements [`StreamTextParser`] so callers can consume:
/// - `visible_text`: normal assistant text with plan blocks removed
/// - `extracted`: ordered plan segments (includes `Normal(...)` segments for ordering fidelity)
#[derive(Debug)]
pub struct ProposedPlanParser {
parser: TaggedLineParser<PlanTag>,
}
impl ProposedPlanParser {
pub fn new() -> Self {
Self {
parser: TaggedLineParser::new(vec![TagSpec {
open: OPEN_TAG,
close: CLOSE_TAG,
tag: PlanTag::ProposedPlan,
}]),
}
}
}
impl Default for ProposedPlanParser {
fn default() -> Self {
Self::new()
}
}
impl StreamTextParser for ProposedPlanParser {
type Extracted = ProposedPlanSegment;
fn push_str(&mut self, chunk: &str) -> StreamTextChunk<Self::Extracted> {
map_segments(self.parser.parse(chunk))
}
fn finish(&mut self) -> StreamTextChunk<Self::Extracted> {
map_segments(self.parser.finish())
}
}
fn map_segments(segments: Vec<TaggedLineSegment<PlanTag>>) -> StreamTextChunk<ProposedPlanSegment> {
let mut out = StreamTextChunk::default();
for segment in segments {
let mapped = match segment {
TaggedLineSegment::Normal(text) => ProposedPlanSegment::Normal(text),
TaggedLineSegment::TagStart(PlanTag::ProposedPlan) => {
ProposedPlanSegment::ProposedPlanStart
}
TaggedLineSegment::TagDelta(PlanTag::ProposedPlan, text) => {
ProposedPlanSegment::ProposedPlanDelta(text)
}
TaggedLineSegment::TagEnd(PlanTag::ProposedPlan) => {
ProposedPlanSegment::ProposedPlanEnd
}
};
if let ProposedPlanSegment::Normal(text) = &mapped {
out.visible_text.push_str(text);
}
out.extracted.push(mapped);
}
out
}
pub fn strip_proposed_plan_blocks(text: &str) -> String {
let mut parser = ProposedPlanParser::new();
let mut out = parser.push_str(text).visible_text;
out.push_str(&parser.finish().visible_text);
out
}
pub fn extract_proposed_plan_text(text: &str) -> Option<String> {
let mut parser = ProposedPlanParser::new();
let mut plan_text = String::new();
let mut saw_plan_block = false;
for segment in parser
.push_str(text)
.extracted
.into_iter()
.chain(parser.finish().extracted)
{
match segment {
ProposedPlanSegment::ProposedPlanStart => {
saw_plan_block = true;
plan_text.clear();
}
ProposedPlanSegment::ProposedPlanDelta(delta) => {
plan_text.push_str(&delta);
}
ProposedPlanSegment::ProposedPlanEnd | ProposedPlanSegment::Normal(_) => {}
}
}
saw_plan_block.then_some(plan_text)
}
#[cfg(test)]
mod tests {
use super::ProposedPlanParser;
use super::ProposedPlanSegment;
use super::extract_proposed_plan_text;
use super::strip_proposed_plan_blocks;
use crate::StreamTextChunk;
use crate::StreamTextParser;
use pretty_assertions::assert_eq;
fn collect_chunks<P>(parser: &mut P, chunks: &[&str]) -> StreamTextChunk<P::Extracted>
where
P: StreamTextParser,
{
let mut all = StreamTextChunk::default();
for chunk in chunks {
let next = parser.push_str(chunk);
all.visible_text.push_str(&next.visible_text);
all.extracted.extend(next.extracted);
}
let tail = parser.finish();
all.visible_text.push_str(&tail.visible_text);
all.extracted.extend(tail.extracted);
all
}
#[test]
fn streams_proposed_plan_segments_and_visible_text() {
let mut parser = ProposedPlanParser::new();
let out = collect_chunks(
&mut parser,
&[
"Intro text\n<prop",
"osed_plan>\n- step 1\n",
"</proposed_plan>\nOutro",
],
);
assert_eq!(out.visible_text, "Intro text\nOutro");
assert_eq!(
out.extracted,
vec![
ProposedPlanSegment::Normal("Intro text\n".to_string()),
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
ProposedPlanSegment::Normal("Outro".to_string()),
]
);
}
#[test]
fn preserves_non_tag_lines() {
let mut parser = ProposedPlanParser::new();
let out = collect_chunks(&mut parser, &[" <proposed_plan> extra\n"]);
assert_eq!(out.visible_text, " <proposed_plan> extra\n");
assert_eq!(
out.extracted,
vec![ProposedPlanSegment::Normal(
" <proposed_plan> extra\n".to_string()
)]
);
}
#[test]
fn closes_unterminated_plan_block_on_finish() {
let mut parser = ProposedPlanParser::new();
let out = collect_chunks(&mut parser, &["<proposed_plan>\n- step 1\n"]);
assert_eq!(out.visible_text, "");
assert_eq!(
out.extracted,
vec![
ProposedPlanSegment::ProposedPlanStart,
ProposedPlanSegment::ProposedPlanDelta("- step 1\n".to_string()),
ProposedPlanSegment::ProposedPlanEnd,
]
);
}
#[test]
fn strips_proposed_plan_blocks_from_text() {
let text = "before\n<proposed_plan>\n- step\n</proposed_plan>\nafter";
assert_eq!(strip_proposed_plan_blocks(text), "before\nafter");
}
#[test]
fn extracts_proposed_plan_text() {
let text = "before\n<proposed_plan>\n- step\n</proposed_plan>\nafter";
assert_eq!(
extract_proposed_plan_text(text),
Some("- step\n".to_string())
);
}
}
@@ -0,0 +1,36 @@
/// Incremental parser result for one pushed chunk (or final flush).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StreamTextChunk<T> {
/// Text safe to render immediately.
pub visible_text: String,
/// Hidden payloads extracted from the chunk.
pub extracted: Vec<T>,
}
impl<T> Default for StreamTextChunk<T> {
fn default() -> Self {
Self {
visible_text: String::new(),
extracted: Vec::new(),
}
}
}
impl<T> StreamTextChunk<T> {
/// Returns true when no visible text or extracted payloads were produced.
pub fn is_empty(&self) -> bool {
self.visible_text.is_empty() && self.extracted.is_empty()
}
}
/// Trait for parsers that consume streamed text and emit visible text plus extracted payloads.
pub trait StreamTextParser {
/// Payload extracted by this parser (for example a citation body).
type Extracted;
/// Feed a new text chunk.
fn push_str(&mut self, chunk: &str) -> StreamTextChunk<Self::Extracted>;
/// Flush any buffered state at end-of-stream (or end-of-item).
fn finish(&mut self) -> StreamTextChunk<Self::Extracted>;
}

Some files were not shown because too many files have changed in this diff Show More