feat(app-server): experimental flag to persist extended history (#11227)

This PR adds an experimental `persist_extended_history` bool flag to
app-server thread APIs so rollout logs can retain a richer set of
EventMsgs for non-lossy Thread > Turn > ThreadItems reconstruction (i.e.
on `thread/resume`).

### Motivation
Today, our rollout recorder only persists a small subset (e.g. user
message, reasoning, assistant message) of `EventMsg` types, dropping a
good number (like command exec, file change, etc.) that are important
for reconstructing full item history for `thread/resume`, `thread/read`,
and `thread/fork`.

Some clients want to be able to resume a thread without lossiness. This
lossiness is primarily a UI thing, since what the model sees are
`ResponseItem` and not `EventMsg`.

### Approach
This change introduces an opt-in `persist_full_history` flag to preserve
those events when you start/resume/fork a thread (defaults to `false`).

This is done by adding an `EventPersistenceMode` to the rollout
recorder:
- `Limited` (existing behavior, default)
- `Extended` (new opt-in behavior)

In `Extended` mode, persist additional `EventMsg` variants needed for
non-lossy app-server `ThreadItem` reconstruction. We now store the
following ThreadItems that we didn't before:
- web search
- command execution
- patch/file changes
- MCP tool calls
- image view calls
- collab tool outcomes
- context compaction
- review mode enter/exit

For **command executions** in particular, we truncate the output using
the existing `truncate_text` from core to store an upper bound of 10,000
bytes, which is also the default value for truncating tool outputs shown
to the model. This keeps the size of the rollout file and command
execution items returned over the wire reasonable.

And we also persist `EventMsg::Error` which we can now map back to the
Turn's status and populates the Turn's error metadata.

#### Updates to EventMsgs
To truly make `thread/resume` non-lossy, we also needed to persist the
`status` on `EventMsg::CommandExecutionEndEvent` and
`EventMsg::PatchApplyEndEvent`. Previously it was not obvious whether a
command failed or was declined (similar for apply_patch). These
EventMsgs were never persisted before so I made it a required field.
This commit is contained in:
Owen Lin 2026-02-12 11:34:22 -08:00 committed by GitHub
parent 22fa283511
commit efc8d45750
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1724 additions and 138 deletions

View file

@ -158,3 +158,5 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially:
`just write-app-server-schema`
(and `just write-app-server-schema --experimental` when experimental API fixtures are affected).
- Validate with `cargo test -p codex-app-server-protocol`.
- Avoid boilerplate tests that only assert experimental field markers for individual
request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead.

1
codex-rs/Cargo.lock generated
View file

@ -1401,6 +1401,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"shlex",
"similar",
"strum_macros 0.27.2",
"tempfile",

View file

@ -20,6 +20,7 @@ codex-utils-absolute-path = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
shlex = { workspace = true }
strum_macros = { workspace = true }
thiserror = { workspace = true }
ts-rs = { workspace = true }

View file

@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -1805,6 +1814,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -1832,6 +1849,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -2873,6 +2891,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@ -3400,6 +3426,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {
@ -6185,6 +6219,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -6213,6 +6255,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -6641,6 +6684,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -6668,6 +6719,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",

View file

@ -1965,6 +1965,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -1993,6 +2001,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -2421,6 +2430,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus2"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -2448,6 +2465,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -3489,6 +3507,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@ -4285,6 +4311,14 @@
],
"type": "string"
},
"PatchApplyStatus2": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PatchChangeKind": {
"oneOf": [
{

View file

@ -3362,6 +3362,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -3390,6 +3398,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -3818,6 +3827,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/v2/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -3845,6 +3862,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -4942,6 +4960,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOneOffCommandParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@ -6446,6 +6472,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View file

@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -1805,6 +1814,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -1832,6 +1849,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -2873,6 +2891,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@ -3400,6 +3426,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View file

@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -1805,6 +1814,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -1832,6 +1849,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -2873,6 +2891,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@ -3400,6 +3426,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View file

@ -1349,6 +1349,14 @@
"default": "agent",
"description": "Where the command originated. Defaults to Agent for backward compatibility."
},
"status": {
"allOf": [
{
"$ref": "#/definitions/ExecCommandStatus"
}
],
"description": "Completion status for this command execution."
},
"stderr": {
"description": "Captured stderr",
"type": "string"
@ -1377,6 +1385,7 @@
"exit_code",
"formatted_output",
"parsed_cmd",
"status",
"stderr",
"stdout",
"turn_id",
@ -1805,6 +1814,14 @@
"description": "The changes that were applied (mirrors PatchApplyBeginEvent::changes).",
"type": "object"
},
"status": {
"allOf": [
{
"$ref": "#/definitions/PatchApplyStatus"
}
],
"description": "Completion status for this patch application."
},
"stderr": {
"description": "Captured stderr (parser errors, IO failures, etc.).",
"type": "string"
@ -1832,6 +1849,7 @@
},
"required": [
"call_id",
"status",
"stderr",
"stdout",
"success",
@ -2873,6 +2891,14 @@
],
"type": "string"
},
"ExecCommandStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"ExecOutputStream": {
"enum": [
"stdout",
@ -3400,6 +3426,14 @@
}
]
},
"PatchApplyStatus": {
"enum": [
"completed",
"failed",
"declined"
],
"type": "string"
},
"PlanItemArg": {
"additionalProperties": false,
"properties": {

View file

@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ExecCommandSource } from "./ExecCommandSource";
import type { ExecCommandStatus } from "./ExecCommandStatus";
import type { ParsedCommand } from "./ParsedCommand";
export type ExecCommandEndEvent = {
@ -56,4 +57,8 @@ duration: string,
/**
* Formatted output from the command, as seen by the model.
*/
formatted_output: string, };
formatted_output: string,
/**
* Completion status for this command execution.
*/
status: ExecCommandStatus, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExecCommandStatus = "completed" | "failed" | "declined";

View file

@ -2,6 +2,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { FileChange } from "./FileChange";
import type { PatchApplyStatus } from "./PatchApplyStatus";
export type PatchApplyEndEvent = {
/**
@ -28,4 +29,8 @@ success: boolean,
/**
* The changes that were applied (mirrors PatchApplyBeginEvent::changes).
*/
changes: { [key in string]?: FileChange }, };
changes: { [key in string]?: FileChange },
/**
* Completion status for this patch application.
*/
status: PatchApplyStatus, };

View file

@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PatchApplyStatus = "completed" | "failed" | "declined";

View file

@ -62,6 +62,7 @@ export type { ExecCommandBeginEvent } from "./ExecCommandBeginEvent";
export type { ExecCommandEndEvent } from "./ExecCommandEndEvent";
export type { ExecCommandOutputDeltaEvent } from "./ExecCommandOutputDeltaEvent";
export type { ExecCommandSource } from "./ExecCommandSource";
export type { ExecCommandStatus } from "./ExecCommandStatus";
export type { ExecOneOffCommandParams } from "./ExecOneOffCommandParams";
export type { ExecOneOffCommandResponse } from "./ExecOneOffCommandResponse";
export type { ExecOutputStream } from "./ExecOutputStream";
@ -129,6 +130,7 @@ export type { NewConversationResponse } from "./NewConversationResponse";
export type { ParsedCommand } from "./ParsedCommand";
export type { PatchApplyBeginEvent } from "./PatchApplyBeginEvent";
export type { PatchApplyEndEvent } from "./PatchApplyEndEvent";
export type { PatchApplyStatus } from "./PatchApplyStatus";
export type { Personality } from "./Personality";
export type { PlanDeltaEvent } from "./PlanDeltaEvent";
export type { PlanItem } from "./PlanItem";

View file

@ -21,4 +21,8 @@ export type ThreadForkParams = {threadId: string, /**
path?: string | null, /**
* Configuration overrides for the forked thread, if any.
*/
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};
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, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
persistExtendedHistory: boolean};

View file

@ -30,4 +30,8 @@ history?: Array<ResponseItem> | null, /**
path?: string | null, /**
* Configuration overrides for the resumed thread, if any.
*/
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};
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, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
persistExtendedHistory: boolean};

View file

@ -10,4 +10,8 @@ export type ThreadStartParams = {model?: string | null, modelProvider?: string |
* If true, opt into emitting raw Responses API items on the event stream.
* This is for internal use only (e.g. Codex Cloud).
*/
experimentalRawEvents: boolean};
experimentalRawEvents: boolean, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on resume/fork/read.
*/
persistExtendedHistory: boolean};

View file

@ -1323,17 +1323,4 @@ mod tests {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("mock/experimentalMethod"));
}
#[test]
fn thread_start_mock_field_is_marked_experimental() {
let request = ClientRequest::ThreadStart {
request_id: RequestId::Integer(1),
params: v2::ThreadStartParams {
mock_experimental_field: Some("mock".to_string()),
..Default::default()
},
};
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/start.mockExperimentalField"));
}
}

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,9 @@ use codex_protocol::protocol::AgentStatus as CoreAgentStatus;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
@ -1422,6 +1424,11 @@ pub struct ThreadStartParams {
#[experimental("thread/start.experimentalRawEvents")]
#[serde(default)]
pub experimental_raw_events: bool,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on resume/fork/read.
#[experimental("thread/start.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
@ -1503,6 +1510,11 @@ pub struct ThreadResumeParams {
pub developer_instructions: Option<String>,
#[ts(optional = nullable)]
pub personality: Option<Personality>,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
#[experimental("thread/resume.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@ -1556,6 +1568,11 @@ pub struct ThreadForkParams {
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
pub developer_instructions: Option<String>,
/// If true, persist additional rollout EventMsg variants required to
/// reconstruct a richer thread history on subsequent resume/fork/read.
#[experimental("thread/fork.persistFullHistory")]
#[serde(default)]
pub persist_extended_history: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
@ -2622,6 +2639,22 @@ pub enum CommandExecutionStatus {
Declined,
}
impl From<CoreExecCommandStatus> for CommandExecutionStatus {
fn from(value: CoreExecCommandStatus) -> Self {
Self::from(&value)
}
}
impl From<&CoreExecCommandStatus> for CommandExecutionStatus {
fn from(value: &CoreExecCommandStatus) -> Self {
match value {
CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed,
CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed,
CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@ -2662,6 +2695,22 @@ pub enum PatchApplyStatus {
Declined,
}
impl From<CorePatchApplyStatus> for PatchApplyStatus {
fn from(value: CorePatchApplyStatus) -> Self {
Self::from(&value)
}
}
impl From<&CorePatchApplyStatus> for PatchApplyStatus {
fn from(value: &CorePatchApplyStatus) -> Self {
match value {
CorePatchApplyStatus::Completed => PatchApplyStatus::Completed,
CorePatchApplyStatus::Failed => PatchApplyStatus::Failed,
CorePatchApplyStatus::Declined => PatchApplyStatus::Declined,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]

View file

@ -209,6 +209,8 @@ To branch from a stored session, call `thread/fork` with the `thread.id`. This c
{ "method": "thread/started", "params": { "thread": { … } } }
```
Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously.
### Example: List threads (with pagination & filters)
`thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of:

View file

@ -717,6 +717,10 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
};
if !ev.affects_turn_status() {
return;
}
let turn_error = TurnError {
message: ev.message,
codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from),
@ -887,11 +891,7 @@ pub(crate) async fn apply_bespoke_event_handling(
// and emit the corresponding EventMsg, we repurpose the call_id as the item_id.
let item_id = patch_end_event.call_id.clone();
let status = if patch_end_event.success {
PatchApplyStatus::Completed
} else {
PatchApplyStatus::Failed
};
let status: PatchApplyStatus = (&patch_end_event.status).into();
let changes = convert_patch_changes(&patch_end_event.changes);
complete_file_change_item(
conversation_id,
@ -998,14 +998,11 @@ pub(crate) async fn apply_bespoke_event_handling(
aggregated_output,
exit_code,
duration,
status,
..
} = exec_command_end_event;
let status = if exit_code == 0 {
CommandExecutionStatus::Completed
} else {
CommandExecutionStatus::Failed
};
let status: CommandExecutionStatus = (&status).into();
let command_actions = parsed_cmd
.into_iter()
.map(V2ParsedCommand::from)

View file

@ -1894,6 +1894,7 @@ impl CodexMessageProcessor {
experimental_raw_events,
personality,
ephemeral,
persist_extended_history,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
@ -1953,7 +1954,7 @@ impl CodexMessageProcessor {
match self
.thread_manager
.start_thread_with_tools(config, core_dynamic_tools)
.start_thread_with_tools(config, core_dynamic_tools, persist_extended_history)
.await
{
Ok(new_conv) => {
@ -2690,6 +2691,7 @@ impl CodexMessageProcessor {
base_instructions,
developer_instructions,
personality,
persist_extended_history,
} = params;
let thread_history = if let Some(history) = history {
@ -2805,7 +2807,12 @@ impl CodexMessageProcessor {
match self
.thread_manager
.resume_thread_with_history(config, thread_history, self.auth_manager.clone())
.resume_thread_with_history(
config,
thread_history,
self.auth_manager.clone(),
persist_extended_history,
)
.await
{
Ok(NewThread {
@ -2910,6 +2917,7 @@ impl CodexMessageProcessor {
config: cli_overrides,
base_instructions,
developer_instructions,
persist_extended_history,
} = params;
let (rollout_path, source_thread_id) = if let Some(path) = path {
@ -3021,7 +3029,12 @@ impl CodexMessageProcessor {
..
} = match self
.thread_manager
.fork_thread(usize::MAX, config, rollout_path.clone())
.fork_thread(
usize::MAX,
config,
rollout_path.clone(),
persist_extended_history,
)
.await
{
Ok(thread) => thread,
@ -3962,7 +3975,7 @@ impl CodexMessageProcessor {
match self
.thread_manager
.resume_thread_with_history(config, thread_history, self.auth_manager.clone())
.resume_thread_with_history(config, thread_history, self.auth_manager.clone(), false)
.await
{
Ok(NewThread {
@ -4162,7 +4175,7 @@ impl CodexMessageProcessor {
..
} = match self
.thread_manager
.fork_thread(usize::MAX, config, rollout_path.clone())
.fork_thread(usize::MAX, config, rollout_path.clone(), false)
.await
{
Ok(thread) => thread,
@ -5168,7 +5181,7 @@ impl CodexMessageProcessor {
..
} = self
.thread_manager
.fork_thread(usize::MAX, config, rollout_path)
.fork_thread(usize::MAX, config, rollout_path, false)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,

View file

@ -50,7 +50,7 @@ impl AgentControl {
let new_thread = match session_source {
Some(session_source) => {
state
.spawn_new_thread_with_source(config, self.clone(), session_source)
.spawn_new_thread_with_source(config, self.clone(), session_source, false)
.await?
}
None => state.spawn_new_thread(config, self.clone()).await?,

View file

@ -196,6 +196,7 @@ use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::rollout::map_session_init_error;
use crate::rollout::metadata;
use crate::rollout::policy::EventPersistenceMode;
use crate::shell;
use crate::shell_snapshot::ShellSnapshot;
use crate::skills::SkillError;
@ -284,6 +285,7 @@ impl Codex {
session_source: SessionSource,
agent_control: AgentControl,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
@ -396,6 +398,7 @@ impl Codex {
original_config_do_not_use: Arc::clone(&config),
session_source,
dynamic_tools,
persist_extended_history,
};
// Generate a unique ID for the lifetime of this Codex session.
@ -733,6 +736,7 @@ pub(crate) struct SessionConfiguration {
/// Source of the session (cli, vscode, exec, mcp, ...)
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
}
impl SessionConfiguration {
@ -984,12 +988,24 @@ impl Session {
text: session_configuration.base_instructions.clone(),
},
session_configuration.dynamic_tools.clone(),
if session_configuration.persist_extended_history {
EventPersistenceMode::Extended
} else {
EventPersistenceMode::Limited
},
),
)
}
InitialHistory::Resumed(resumed_history) => (
resumed_history.conversation_id,
RolloutRecorderParams::resume(resumed_history.rollout_path.clone()),
RolloutRecorderParams::resume(
resumed_history.rollout_path.clone(),
if session_configuration.persist_extended_history {
EventPersistenceMode::Extended
} else {
EventPersistenceMode::Limited
},
),
),
};
let state_builder = match &initial_history {
@ -6297,6 +6313,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
};
let mut state = SessionState::new(session_configuration);
@ -6387,6 +6404,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
};
let mut state = SessionState::new(session_configuration);
@ -6696,6 +6714,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
}
}
@ -6748,6 +6767,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@ -6893,6 +6913,7 @@ mod tests {
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(

View file

@ -58,6 +58,7 @@ pub(crate) async fn run_codex_thread_interactive(
SessionSource::SubAgent(SubAgentSource::Review),
parent_session.services.agent_control.clone(),
Vec::new(),
false,
)
.await?;
let codex = Arc::new(codex);

View file

@ -124,6 +124,7 @@ pub use rollout::list::ThreadsPage;
pub use rollout::list::parse_cursor;
pub use rollout::list::read_head_for_summary;
pub use rollout::list::read_session_meta_line;
pub use rollout::policy::EventPersistenceMode;
pub use rollout::rollout_date_parts;
pub use rollout::session_index::find_thread_names_by_ids;
mod function_tool;

View file

@ -2,12 +2,20 @@ use crate::protocol::EventMsg;
use crate::protocol::RolloutItem;
use codex_protocol::models::ResponseItem;
/// Whether a rollout `item` should be persisted in rollout files.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EventPersistenceMode {
#[default]
Limited,
Extended,
}
/// Whether a rollout `item` should be persisted in rollout files for the
/// provided persistence `mode`.
#[inline]
pub(crate) fn is_persisted_response_item(item: &RolloutItem) -> bool {
pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool {
match item {
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev),
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode),
// Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns).
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {
true
@ -51,9 +59,33 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) ->
}
}
/// Whether an `EventMsg` should be persisted in rollout files.
/// Whether an `EventMsg` should be persisted in rollout files for the
/// provided persistence `mode`.
#[inline]
pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
pub(crate) fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool {
match mode {
EventPersistenceMode::Limited => should_persist_event_msg_limited(ev),
EventPersistenceMode::Extended => should_persist_event_msg_extended(ev),
}
}
fn should_persist_event_msg_limited(ev: &EventMsg) -> bool {
matches!(
event_msg_persistence_mode(ev),
Some(EventPersistenceMode::Limited)
)
}
fn should_persist_event_msg_extended(ev: &EventMsg) -> bool {
matches!(
event_msg_persistence_mode(ev),
Some(EventPersistenceMode::Limited) | Some(EventPersistenceMode::Extended)
)
}
/// Returns the minimum persistence mode that includes this event.
/// `None` means the event should never be persisted.
fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
match ev {
EventMsg::UserMessage(_)
| EventMsg::AgentMessage(_)
@ -67,15 +99,29 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::UndoCompleted(_)
| EventMsg::TurnAborted(_)
| EventMsg::TurnStarted(_)
| EventMsg::TurnComplete(_) => true,
| EventMsg::TurnComplete(_) => Some(EventPersistenceMode::Limited),
EventMsg::ItemCompleted(event) => {
// Plan items are derived from streaming tags and are not part of the
// raw ResponseItem history, so we persist their completion to replay
// them on resume without bloating rollouts with every item lifecycle.
matches!(event.item, codex_protocol::items::TurnItem::Plan(_))
if matches!(event.item, codex_protocol::items::TurnItem::Plan(_)) {
Some(EventPersistenceMode::Limited)
} else {
None
}
}
EventMsg::Error(_)
| EventMsg::Warning(_)
| EventMsg::WebSearchEnd(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::PatchApplyEnd(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::ViewImageToolCall(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeEnd(_) => Some(EventPersistenceMode::Extended),
EventMsg::Warning(_)
| EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::AgentReasoningRawContentDelta(_)
@ -84,13 +130,10 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::SessionConfigured(_)
| EventMsg::ThreadNameUpdated(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::WebSearchBegin(_)
| EventMsg::WebSearchEnd(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_)
| EventMsg::ExecCommandEnd(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
@ -99,7 +142,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::BackgroundEvent(_)
| EventMsg::StreamError(_)
| EventMsg::PatchApplyBegin(_)
| EventMsg::PatchApplyEnd(_)
| EventMsg::TurnDiff(_)
| EventMsg::GetHistoryEntryResponse(_)
| EventMsg::UndoStarted(_)
@ -112,7 +154,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
| EventMsg::ViewImageToolCall(_)
| EventMsg::DeprecationNotice(_)
| EventMsg::ItemStarted(_)
| EventMsg::AgentMessageContentDelta(_)
@ -121,14 +162,9 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
| EventMsg::CollabAgentInteractionEnd(_)
| EventMsg::CollabWaitingBegin(_)
| EventMsg::CollabWaitingEnd(_)
| EventMsg::CollabCloseBegin(_)
| EventMsg::CollabCloseEnd(_)
| EventMsg::CollabResumeBegin(_)
| EventMsg::CollabResumeEnd(_) => false,
| EventMsg::CollabResumeBegin(_) => None,
}
}

View file

@ -36,6 +36,7 @@ use super::list::get_threads_in_root;
use super::list::parse_cursor;
use super::list::parse_timestamp_uuid_from_filename;
use super::metadata;
use super::policy::EventPersistenceMode;
use super::policy::is_persisted_response_item;
use crate::config::Config;
use crate::default_client::originator;
@ -43,6 +44,9 @@ use crate::git_info::collect_git_info;
use crate::path_utils;
use crate::state_db;
use crate::state_db::StateDbHandle;
use crate::truncate::TruncationPolicy;
use crate::truncate::truncate_text;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::RolloutItem;
@ -67,6 +71,7 @@ pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
pub(crate) rollout_path: PathBuf,
state_db: Option<StateDbHandle>,
event_persistence_mode: EventPersistenceMode,
}
#[derive(Clone)]
@ -77,9 +82,11 @@ pub enum RolloutRecorderParams {
source: SessionSource,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
},
Resume {
path: PathBuf,
event_persistence_mode: EventPersistenceMode,
},
}
@ -104,6 +111,7 @@ impl RolloutRecorderParams {
source: SessionSource,
base_instructions: BaseInstructions,
dynamic_tools: Vec<DynamicToolSpec>,
event_persistence_mode: EventPersistenceMode,
) -> Self {
Self::Create {
conversation_id,
@ -111,11 +119,42 @@ impl RolloutRecorderParams {
source,
base_instructions,
dynamic_tools,
event_persistence_mode,
}
}
pub fn resume(path: PathBuf) -> Self {
Self::Resume { path }
pub fn resume(path: PathBuf, event_persistence_mode: EventPersistenceMode) -> Self {
Self::Resume {
path,
event_persistence_mode,
}
}
}
const PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES: usize = 10_000;
fn sanitize_rollout_item_for_persistence(
item: RolloutItem,
mode: EventPersistenceMode,
) -> RolloutItem {
if mode != EventPersistenceMode::Extended {
return item;
}
match item {
RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => {
// Persist only a bounded aggregated summary of command output.
event.aggregated_output = truncate_text(
&event.aggregated_output,
TruncationPolicy::Bytes(PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES),
);
// Drop unnecessary fields from rollout storage since aggregated_output is all we need.
event.stdout.clear();
event.stderr.clear();
event.formatted_output.clear();
RolloutItem::EventMsg(EventMsg::ExecCommandEnd(event))
}
_ => item,
}
}
@ -322,58 +361,70 @@ impl RolloutRecorder {
state_db_ctx: Option<StateDbHandle>,
state_builder: Option<ThreadMetadataBuilder>,
) -> std::io::Result<Self> {
let (file, deferred_log_file_info, rollout_path, meta) = match params {
RolloutRecorderParams::Create {
conversation_id,
forked_from_id,
source,
base_instructions,
dynamic_tools,
} => {
let log_file_info = precompute_log_file_info(config, conversation_id)?;
let path = log_file_info.path.clone();
let session_id = log_file_info.conversation_id;
let started_at = log_file_info.timestamp;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = started_at
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let session_meta = SessionMeta {
id: session_id,
let (file, deferred_log_file_info, rollout_path, meta, event_persistence_mode) =
match params {
RolloutRecorderParams::Create {
conversation_id,
forked_from_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
dynamic_tools: if dynamic_tools.is_empty() {
None
} else {
Some(dynamic_tools)
},
};
base_instructions,
dynamic_tools,
event_persistence_mode,
} => {
let log_file_info = precompute_log_file_info(config, conversation_id)?;
let path = log_file_info.path.clone();
let session_id = log_file_info.conversation_id;
let started_at = log_file_info.timestamp;
(None, Some(log_file_info), path, Some(session_meta))
}
RolloutRecorderParams::Resume { path } => (
Some(
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
);
let timestamp = started_at
.to_offset(time::UtcOffset::UTC)
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let session_meta = SessionMeta {
id: session_id,
forked_from_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
source,
model_provider: Some(config.model_provider_id.clone()),
base_instructions: Some(base_instructions),
dynamic_tools: if dynamic_tools.is_empty() {
None
} else {
Some(dynamic_tools)
},
};
(
None,
Some(log_file_info),
path,
Some(session_meta),
event_persistence_mode,
)
}
RolloutRecorderParams::Resume {
path,
event_persistence_mode,
} => (
Some(
tokio::fs::OpenOptions::new()
.append(true)
.open(&path)
.await?,
),
None,
path,
None,
event_persistence_mode,
),
None,
path,
None,
),
};
};
// Clone the cwd for the spawned task to collect git info asynchronously
let cwd = config.cwd.clone();
@ -402,6 +453,7 @@ impl RolloutRecorder {
tx,
rollout_path,
state_db: state_db_ctx,
event_persistence_mode,
})
}
@ -419,8 +471,11 @@ impl RolloutRecorder {
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
if is_persisted_response_item(item) {
filtered.push(item.clone());
if is_persisted_response_item(item, self.event_persistence_mode) {
filtered.push(sanitize_rollout_item_for_persistence(
item.clone(),
self.event_persistence_mode,
));
}
}
if filtered.is_empty() {
@ -673,9 +728,7 @@ async fn rollout_writer(
RolloutCmd::AddItems(items) => {
let mut persisted_items = Vec::new();
for item in items {
if is_persisted_response_item(&item) {
persisted_items.push(item);
}
persisted_items.push(item);
}
if persisted_items.is_empty() {
continue;
@ -1003,6 +1056,7 @@ mod tests {
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,

View file

@ -21,6 +21,7 @@ use crate::protocol::EventMsg;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::ExecCommandSource;
use crate::protocol::ExecCommandStatus;
use crate::protocol::SandboxPolicy;
use crate::protocol::TurnStartedEvent;
use crate::sandboxing::ExecRequest;
@ -207,6 +208,7 @@ pub(crate) async fn execute_user_shell_command(
exit_code: -1,
duration: Duration::ZERO,
formatted_output: aborted_message,
status: ExecCommandStatus::Failed,
}),
)
.await;
@ -233,6 +235,11 @@ pub(crate) async fn execute_user_shell_command(
&output,
turn_context.truncation_policy,
),
status: if output.exit_code == 0 {
ExecCommandStatus::Completed
} else {
ExecCommandStatus::Failed
},
}),
)
.await;
@ -272,6 +279,7 @@ pub(crate) async fn execute_user_shell_command(
&exec_output,
turn_context.truncation_policy,
),
status: ExecCommandStatus::Failed,
}),
)
.await;

View file

@ -277,13 +277,15 @@ impl ThreadManager {
}
pub async fn start_thread(&self, config: Config) -> CodexResult<NewThread> {
self.start_thread_with_tools(config, Vec::new()).await
self.start_thread_with_tools(config, Vec::new(), false)
.await
}
pub async fn start_thread_with_tools(
&self,
config: Config,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.state
.spawn_thread(
@ -292,6 +294,7 @@ impl ThreadManager {
Arc::clone(&self.state.auth_manager),
self.agent_control(),
dynamic_tools,
persist_extended_history,
)
.await
}
@ -303,7 +306,7 @@ impl ThreadManager {
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
self.resume_thread_with_history(config, initial_history, auth_manager)
self.resume_thread_with_history(config, initial_history, auth_manager, false)
.await
}
@ -312,6 +315,7 @@ impl ThreadManager {
config: Config,
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.state
.spawn_thread(
@ -320,6 +324,7 @@ impl ThreadManager {
auth_manager,
self.agent_control(),
Vec::new(),
persist_extended_history,
)
.await
}
@ -349,6 +354,7 @@ impl ThreadManager {
nth_user_message: usize,
config: Config,
path: PathBuf,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
let history = RolloutRecorder::get_rollout_history(&path).await?;
let history = truncate_before_nth_user_message(history, nth_user_message);
@ -359,6 +365,7 @@ impl ThreadManager {
Arc::clone(&self.state.auth_manager),
self.agent_control(),
Vec::new(),
persist_extended_history,
)
.await
}
@ -409,7 +416,7 @@ impl ThreadManagerState {
config: Config,
agent_control: AgentControl,
) -> CodexResult<NewThread> {
self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone())
self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone(), false)
.await
}
@ -418,6 +425,7 @@ impl ThreadManagerState {
config: Config,
agent_control: AgentControl,
session_source: SessionSource,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@ -426,6 +434,7 @@ impl ThreadManagerState {
agent_control,
session_source,
Vec::new(),
persist_extended_history,
)
.await
}
@ -445,6 +454,7 @@ impl ThreadManagerState {
agent_control,
session_source,
Vec::new(),
false,
)
.await
}
@ -457,6 +467,7 @@ impl ThreadManagerState {
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@ -465,10 +476,12 @@ impl ThreadManagerState {
agent_control,
self.session_source.clone(),
dynamic_tools,
persist_extended_history,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn_thread_with_source(
&self,
config: Config,
@ -477,6 +490,7 @@ impl ThreadManagerState {
agent_control: AgentControl,
session_source: SessionSource,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
persist_extended_history: bool,
) -> CodexResult<NewThread> {
self.file_watcher.register_config(&config);
let CodexSpawnOk {
@ -491,6 +505,7 @@ impl ThreadManagerState {
session_source,
agent_control,
dynamic_tools,
persist_extended_history,
)
.await?;
self.finalize_thread_spawn(codex, thread_id).await

View file

@ -9,9 +9,11 @@ use crate::protocol::EventMsg;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::ExecCommandSource;
use crate::protocol::ExecCommandStatus;
use crate::protocol::FileChange;
use crate::protocol::PatchApplyBeginEvent;
use crate::protocol::PatchApplyEndEvent;
use crate::protocol::PatchApplyStatus;
use crate::protocol::TurnDiffEvent;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::sandboxing::ToolError;
@ -56,6 +58,7 @@ pub(crate) enum ToolEventStage {
pub(crate) enum ToolEventFailure {
Output(ExecToolCallOutput),
Message(String),
Rejected(String),
}
pub(crate) async fn emit_exec_command_begin(
@ -195,6 +198,11 @@ impl ToolEmitter {
output.stdout.text.clone(),
output.stderr.text.clone(),
output.exit_code == 0,
if output.exit_code == 0 {
PatchApplyStatus::Completed
} else {
PatchApplyStatus::Failed
},
)
.await;
}
@ -208,6 +216,11 @@ impl ToolEmitter {
output.stdout.text.clone(),
output.stderr.text.clone(),
output.exit_code == 0,
if output.exit_code == 0 {
PatchApplyStatus::Completed
} else {
PatchApplyStatus::Failed
},
)
.await;
}
@ -221,6 +234,21 @@ impl ToolEmitter {
String::new(),
(*message).to_string(),
false,
PatchApplyStatus::Failed,
)
.await;
}
(
Self::ApplyPatch { changes, .. },
ToolEventStage::Failure(ToolEventFailure::Rejected(message)),
) => {
emit_patch_end(
ctx,
changes.clone(),
String::new(),
(*message).to_string(),
false,
PatchApplyStatus::Declined,
)
.await;
}
@ -301,6 +329,13 @@ impl ToolEmitter {
Err(ToolError::Rejected(msg)) => {
// Normalize common rejection messages for exec tools so tests and
// users see a clear, consistent phrase.
//
// NOTE: ToolError::Rejected is currently used for both user-declined approvals
// and some operational/runtime rejection paths (for example setup failures).
// We intentionally map all of them through the "rejected" event path for now,
// which means a subset of non-user failures may be reported as Declined.
//
// TODO: We should add a new ToolError variant for user-declined approvals.
let normalized = if msg == "rejected by user" {
match self {
Self::Shell { .. } | Self::UnifiedExec { .. } => {
@ -311,7 +346,7 @@ impl ToolEmitter {
} else {
msg
};
let event = ToolEventStage::Failure(ToolEventFailure::Message(normalized.clone()));
let event = ToolEventStage::Failure(ToolEventFailure::Rejected(normalized.clone()));
let result = Err(FunctionCallError::RespondToModel(normalized));
(event, result)
}
@ -357,6 +392,7 @@ struct ExecCommandResult {
exit_code: i32,
duration: Duration,
formatted_output: String,
status: ExecCommandStatus,
}
async fn emit_exec_stage(
@ -386,6 +422,11 @@ async fn emit_exec_stage(
exit_code: output.exit_code,
duration: output.duration,
formatted_output: format_exec_output_str(&output, ctx.turn.truncation_policy),
status: if output.exit_code == 0 {
ExecCommandStatus::Completed
} else {
ExecCommandStatus::Failed
},
};
emit_exec_end(ctx, exec_input, exec_result).await;
}
@ -398,6 +439,20 @@ async fn emit_exec_stage(
exit_code: -1,
duration: Duration::ZERO,
formatted_output: text,
status: ExecCommandStatus::Failed,
};
emit_exec_end(ctx, exec_input, exec_result).await;
}
ToolEventStage::Failure(ToolEventFailure::Rejected(message)) => {
let text = message.to_string();
let exec_result = ExecCommandResult {
stdout: String::new(),
stderr: text.clone(),
aggregated_output: text.clone(),
exit_code: -1,
duration: Duration::ZERO,
formatted_output: text,
status: ExecCommandStatus::Declined,
};
emit_exec_end(ctx, exec_input, exec_result).await;
}
@ -427,6 +482,7 @@ async fn emit_exec_end(
exit_code: exec_result.exit_code,
duration: exec_result.duration,
formatted_output: exec_result.formatted_output,
status: exec_result.status,
}),
)
.await;
@ -438,6 +494,7 @@ async fn emit_patch_end(
stdout: String,
stderr: String,
success: bool,
status: PatchApplyStatus,
) {
ctx.session
.send_event(
@ -449,6 +506,7 @@ async fn emit_patch_end(
stderr,
success,
changes,
status,
}),
)
.await;

View file

@ -1301,6 +1301,7 @@ mod tests {
phase: None,
})]),
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
false,
)
.await
.expect("start thread");

View file

@ -1019,7 +1019,7 @@ async fn fork_thread(
nth_user_message: usize,
) -> Arc<CodexThread> {
manager
.fork_thread(nth_user_message, config.clone(), path)
.fork_thread(nth_user_message, config.clone(), path, false)
.await
.expect("fork conversation")
.thread

View file

@ -110,7 +110,7 @@ async fn fork_thread_twice_drops_to_first_message() {
thread: codex_fork1,
..
} = thread_manager
.fork_thread(1, config_for_fork.clone(), base_path.clone())
.fork_thread(1, config_for_fork.clone(), base_path.clone(), false)
.await
.expect("fork 1");
@ -129,7 +129,7 @@ async fn fork_thread_twice_drops_to_first_message() {
thread: codex_fork2,
..
} = thread_manager
.fork_thread(0, config_for_fork.clone(), fork1_path.clone())
.fork_thread(0, config_for_fork.clone(), fork1_path.clone(), false)
.await
.expect("fork 2");

View file

@ -413,7 +413,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> {
fork_config.approval_policy = Constrained::allow_any(AskForApproval::UnlessTrusted);
let forked = initial
.thread_manager
.fork_thread(usize::MAX, fork_config, rollout_path)
.fork_thread(usize::MAX, fork_config, rollout_path, false)
.await?;
forked
.thread

View file

@ -68,7 +68,7 @@ async fn emits_warning_when_resumed_model_differs() {
thread: conversation,
..
} = thread_manager
.resume_thread_with_history(config, initial_history, auth_manager)
.resume_thread_with_history(config, initial_history, auth_manager, false)
.await
.expect("resume conversation");

View file

@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use chrono::Utc;
use codex_core::EventPersistenceMode;
use codex_core::RolloutRecorder;
use codex_core::RolloutRecorderParams;
use codex_core::config::ConfigBuilder;
@ -171,6 +172,7 @@ async fn find_locates_rollout_file_written_by_recorder() -> std::io::Result<()>
SessionSource::Exec,
BaseInstructions::default(),
Vec::new(),
EventPersistenceMode::Limited,
),
None,
None,

View file

@ -39,7 +39,7 @@ async fn emits_warning_when_unstable_features_enabled_via_config() {
thread: conversation,
..
} = thread_manager
.resume_thread_with_history(config, InitialHistory::New, auth_manager)
.resume_thread_with_history(config, InitialHistory::New, auth_manager, false)
.await
.expect("spawn conversation");
@ -77,7 +77,7 @@ async fn suppresses_warning_when_configured() {
thread: conversation,
..
} = thread_manager
.resume_thread_with_history(config, InitialHistory::New, auth_manager)
.resume_thread_with_history(config, InitialHistory::New, auth_manager, false)
.await
.expect("spawn conversation");

View file

@ -11,12 +11,14 @@ use codex_core::protocol::EventMsg;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_core::protocol::FileChange;
use codex_core::protocol::McpInvocation;
use codex_core::protocol::McpToolCallBeginEvent;
use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_core::protocol::SandboxPolicy;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::WarningEvent;
@ -901,6 +903,7 @@ fn exec_command_end_success_produces_completed_command_item() {
exit_code: 0,
duration: Duration::from_millis(5),
formatted_output: String::new(),
status: CoreExecCommandStatus::Completed,
}),
);
let out_ok = ep.collect_thread_events(&end_ok);
@ -988,6 +991,7 @@ fn command_execution_output_delta_updates_item_progress() {
exit_code: 0,
duration: Duration::from_millis(3),
formatted_output: String::new(),
status: CoreExecCommandStatus::Completed,
}),
);
let out_end = ep.collect_thread_events(&end);
@ -1061,6 +1065,7 @@ fn exec_command_end_failure_produces_failed_command_item() {
exit_code: 1,
duration: Duration::from_millis(2),
formatted_output: String::new(),
status: CoreExecCommandStatus::Failed,
}),
);
let out_fail = ep.collect_thread_events(&end_fail);
@ -1102,6 +1107,7 @@ fn exec_command_end_without_begin_is_ignored() {
exit_code: 0,
duration: Duration::from_millis(1),
formatted_output: String::new(),
status: CoreExecCommandStatus::Completed,
}),
);
let out = ep.collect_thread_events(&end_only);
@ -1157,6 +1163,7 @@ fn patch_apply_success_produces_item_completed_patchapply() {
stderr: String::new(),
success: true,
changes: changes.clone(),
status: CorePatchApplyStatus::Completed,
}),
);
let out_end = ep.collect_thread_events(&end);
@ -1228,6 +1235,7 @@ fn patch_apply_failure_produces_item_completed_patchapply_failed() {
stderr: "failed to apply".to_string(),
success: false,
changes: changes.clone(),
status: CorePatchApplyStatus::Failed,
}),
);
let out_end = ep.collect_thread_events(&end);

View file

@ -1160,6 +1160,27 @@ pub enum CodexErrorInfo {
Other,
}
impl CodexErrorInfo {
/// Whether this error should mark the current turn as failed when replaying history.
pub fn affects_turn_status(&self) -> bool {
match self {
Self::ThreadRollbackFailed => false,
Self::ContextWindowExceeded
| Self::UsageLimitExceeded
| Self::ServerOverloaded
| Self::HttpConnectionFailed { .. }
| Self::ResponseStreamConnectionFailed { .. }
| Self::InternalServerError
| Self::Unauthorized
| Self::BadRequest
| Self::SandboxError
| Self::ResponseStreamDisconnected { .. }
| Self::ResponseTooManyFailedAttempts { .. }
| Self::Other => true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
pub struct RawResponseItemEvent {
pub item: ResponseItem,
@ -1297,6 +1318,15 @@ pub struct ErrorEvent {
pub codex_error_info: Option<CodexErrorInfo>,
}
impl ErrorEvent {
/// Whether this error should mark the current turn as failed when replaying history.
pub fn affects_turn_status(&self) -> bool {
self.codex_error_info
.as_ref()
.is_none_or(CodexErrorInfo::affects_turn_status)
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct WarningEvent {
pub message: String,
@ -2051,6 +2081,14 @@ pub enum ExecCommandSource {
UnifiedExecInteraction,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum ExecCommandStatus {
Completed,
Failed,
Declined,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ExecCommandBeginEvent {
/// Identifier so this can be paired with the ExecCommandEnd event.
@ -2112,6 +2150,8 @@ pub struct ExecCommandEndEvent {
pub duration: Duration,
/// Formatted output from the command, as seen by the model.
pub formatted_output: String,
/// Completion status for this command execution.
pub status: ExecCommandStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@ -2235,6 +2275,16 @@ pub struct PatchApplyEndEvent {
/// The changes that were applied (mirrors PatchApplyBeginEvent::changes).
#[serde(default)]
pub changes: HashMap<PathBuf, FileChange>,
/// Completion status for this patch application.
pub status: PatchApplyStatus,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
Completed,
Failed,
Declined,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
@ -2789,6 +2839,24 @@ mod tests {
assert!(event.as_legacy_events(false).is_empty());
}
#[test]
fn rollback_failed_error_does_not_affect_turn_status() {
let event = ErrorEvent {
message: "rollback failed".into(),
codex_error_info: Some(CodexErrorInfo::ThreadRollbackFailed),
};
assert!(!event.affects_turn_status());
}
#[test]
fn generic_error_affects_turn_status() {
let event = ErrorEvent {
message: "generic".into(),
codex_error_info: Some(CodexErrorInfo::Other),
};
assert!(event.affects_turn_status());
}
#[test]
fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> {
let op = Op::UserInput {

View file

@ -1078,7 +1078,7 @@ impl App {
SessionSelection::Fork(path) => {
otel_manager.counter("codex.thread.fork", 1, &[("source", "cli_subcommand")]);
let forked = thread_manager
.fork_thread(usize::MAX, config.clone(), path.clone())
.fork_thread(usize::MAX, config.clone(), path.clone(), false)
.await
.wrap_err_with(|| {
let path_display = path.display();
@ -1463,7 +1463,7 @@ impl App {
if path.exists() {
match self
.server
.fork_thread(usize::MAX, self.config.clone(), path.clone())
.fork_thread(usize::MAX, self.config.clone(), path.clone(), false)
.await
{
Ok(forked) => {

View file

@ -36,6 +36,7 @@ use codex_core::protocol::ExecApprovalRequestEvent;
use codex_core::protocol::ExecCommandBeginEvent;
use codex_core::protocol::ExecCommandEndEvent;
use codex_core::protocol::ExecCommandSource;
use codex_core::protocol::ExecCommandStatus as CoreExecCommandStatus;
use codex_core::protocol::ExecPolicyAmendment;
use codex_core::protocol::ExitedReviewModeEvent;
use codex_core::protocol::FileChange;
@ -46,6 +47,7 @@ use codex_core::protocol::McpStartupUpdateEvent;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::PatchApplyEndEvent;
use codex_core::protocol::PatchApplyStatus as CorePatchApplyStatus;
use codex_core::protocol::RateLimitWindow;
use codex_core::protocol::ReviewRequest;
use codex_core::protocol::ReviewTarget;
@ -2180,6 +2182,11 @@ fn end_exec(
exit_code,
duration: std::time::Duration::from_millis(5),
formatted_output: aggregated,
status: if exit_code == 0 {
CoreExecCommandStatus::Completed
} else {
CoreExecCommandStatus::Failed
},
}),
});
}
@ -2641,6 +2648,7 @@ async fn exec_end_without_begin_uses_event_command() {
exit_code: 0,
duration: std::time::Duration::from_millis(5),
formatted_output: "done".to_string(),
status: CoreExecCommandStatus::Completed,
}),
});
@ -5284,6 +5292,7 @@ async fn apply_patch_events_emit_history_cells() {
stderr: String::new(),
success: true,
changes: end_changes,
status: CorePatchApplyStatus::Completed,
};
chat.handle_codex_event(Event {
id: "s1".into(),
@ -5511,6 +5520,7 @@ async fn apply_patch_full_flow_integration_like() {
stderr: String::new(),
success: true,
changes: end_changes,
status: CorePatchApplyStatus::Completed,
}),
});
}
@ -6101,6 +6111,7 @@ async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
exit_code: 0,
duration: std::time::Duration::from_millis(16000),
formatted_output: String::new(),
status: CoreExecCommandStatus::Completed,
}),
});
chat.handle_codex_event(Event {