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
Unverified
parent 22fa283511
commit efc8d45750
43 changed files with 1724 additions and 138 deletions
+59 -1
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;
@@ -1301,6 +1301,7 @@ mod tests {
phase: None,
})]),
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
false,
)
.await
.expect("start thread");