Preserve MCP resource origins across compaction (#39192)

## Why

Compaction can remove the tool-call events that associate an MCP app widget with
the tool, account, and URI needed to authorize later resource reads.

## What changed

- Store a bounded MCP resource-origin checkpoint with each compacted rollout.
- Restore the checkpoint when resuming a session so existing widgets remain
  readable after compaction and restart.
- Discard saved origins when rollback rewrites history across the compaction
  boundary, and reject oversized or invalid checkpoints during restoration.

## Testing

- Extend the app-server MCP resource-origin test to compact a paginated thread,
  continue the conversation, and read the original widgets before and after a
  restart.
- Verify rollback clears provenance that no longer matches retained history.

GitOrigin-RevId: 7f91c6202fe8d9be21c78dda42fa205937c44263
This commit is contained in:
jif authored and copyberry committed 2026-08-18 12:37:41 +00:00
1 parent 880f1135ea
commit a397079287
22 files changed
+254 -18

No files matched your search

@@ -623,6 +623,7 @@ fn compacted(message: &str, replacement_history: Option<Vec<ResponseItem>>) -> R
message: message.to_string(),
replacement_history: replacement_history
.map(|items| items.into_iter().map(Into::into).collect()),
mcp_resource_origins: None,
window_number: Some(1),
first_window_id: None,
previous_window_id: None,
@@ -240,6 +240,7 @@ impl RollbackPlanner {
.filter_map(|mut frame| {
if Some(frame.record_index) == replay_anchor {
frame.item.replacement_history = Some(Vec::new());
frame.item.mcp_resource_origins = None;
return Some((frame.record_index, frame.item));
}
frame
@@ -316,6 +317,7 @@ impl RollbackPlanner {
let post_compaction_turns = depth_before.saturating_sub(frame.boundary_depth);
let remaining = count.saturating_sub(post_compaction_turns);
if remaining > 0 {
frame.item.mcp_resource_origins = None;
let replacement_history =
frame.item.replacement_history.as_mut().ok_or_else(|| {
migration_error(
@@ -15,6 +15,8 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::McpResourceOrigin;
use codex_protocol::mcp::McpResourceOriginCheckpoint;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentMessageEvent;
@@ -220,6 +222,7 @@ fn compacted(replacement_history: Vec<ResponseItem>) -> RolloutItem {
RolloutItem::Compacted(CompactedItem {
message: "checkpoint".to_string(),
replacement_history: Some(replacement_history.into_iter().map(Into::into).collect()),
mcp_resource_origins: None,
window_number: Some(1),
first_window_id: None,
previous_window_id: None,
@@ -1019,6 +1022,33 @@ async fn migration_rolls_back_inter_agent_metadata_with_its_delivery() {
async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
let home = TempDir::new().expect("create Codex home");
let thread_id = ThreadId::new();
let RolloutItem::Compacted(mut checkpoint) = compacted(vec![
input_response_message("user", "old question"),
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "old answer".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
]) else {
unreachable!("compacted helper always creates a compaction checkpoint");
};
checkpoint.mcp_resource_origins = Some(McpResourceOriginCheckpoint {
origins: vec![McpResourceOrigin {
call_id: "widget-call".to_string(),
turn_id: Some("keep-before-compaction".to_string()),
tool: "_product_search".to_string(),
connector_id: "shopping".to_string(),
link_id: None,
uri: "ui://shopping/widget".to_string(),
ambiguous_account: false,
}],
turns: vec!["keep-before-compaction".to_string()],
current_turn_id: Some("keep-before-compaction".to_string()),
});
let path = write_rollout(
home.path(),
thread_id,
@@ -1027,18 +1057,7 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
started("keep-before-compaction"),
user_message("old question"),
completed("keep-before-compaction"),
compacted(vec![
input_response_message("user", "old question"),
ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "old answer".to_string(),
}],
phase: None,
internal_chat_message_metadata_passthrough: None,
},
]),
RolloutItem::Compacted(checkpoint),
started("remove-after-compaction"),
user_message("new question"),
completed("remove-after-compaction"),
@@ -1067,14 +1086,15 @@ async fn migration_rolls_back_pre_compaction_turns_from_sqlite_history() {
.await
.expect("read rollback-through-compaction turns");
assert_eq!(turns.turns.len(), 1);
let replacement_history = read_rollout(&path)
let checkpoint = read_rollout(&path)
.into_iter()
.find_map(|line| match line.item {
RolloutItem::Compacted(item) => item.replacement_history,
RolloutItem::Compacted(item) => Some(item),
_ => None,
})
.expect("retained compaction");
assert!(replacement_history.is_empty());
assert_eq!(checkpoint.replacement_history, Some(Vec::new()));
assert_eq!(checkpoint.mcp_resource_origins, None);
}
#[tokio::test]
@@ -1363,6 +1383,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() {
RolloutItem::Compacted(CompactedItem {
message: "superseded checkpoint".repeat(1024),
replacement_history: Some(Vec::new()),
mcp_resource_origins: None,
window_number: Some(1),
first_window_id: None,
previous_window_id: None,
@@ -1382,6 +1403,7 @@ async fn migration_compacts_subagent_prefix_and_does_not_project_it() {
}
.into(),
]),
mcp_resource_origins: None,
window_number: Some(2),
first_window_id: None,
previous_window_id: None,