mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Merge 05544a1e27 into sapling-pr-archive-bolinfest
This commit is contained in:
Generated
+128
-102
File diff suppressed because one or more lines are too long
Generated
+360
-492
File diff suppressed because it is too large
Load Diff
@@ -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, };
|
||||
+5
@@ -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:?}");
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}"))?,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =========
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
@@ -181,6 +181,7 @@ impl AppServerClient {
|
||||
source_kinds: None,
|
||||
archived: None,
|
||||
cwd: None,
|
||||
search_term: None,
|
||||
},
|
||||
};
|
||||
self.send(&request)?;
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 != ")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "test-macros",
|
||||
crate_name = "codex_test_macros",
|
||||
proc_macro = True,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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................"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -678,6 +678,7 @@ async fn run_ratatui_app(
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
Some(provider_filter.as_slice()),
|
||||
&config.model_provider_id,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "stream-parser",
|
||||
crate_name = "codex_utils_stream_parser",
|
||||
)
|
||||
@@ -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 }
|
||||
@@ -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: "",
|
||||
}]);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user