From a397079287e6638b39dda329835350d93222681f Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 18 Aug 2026 12:32:01 +0000 Subject: [PATCH] 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 --- .../app-server-exports-stable.json.zst | Bin 134916 -> 135108 bytes .../src/protocol/thread_history.rs | 1 + .../thread_history_projection_tests.rs | 1 + .../tests/suite/v2/mcp_resource_origin.rs | 56 ++++++++++++++- .../tests/suite/v2/thread_resume.rs | 1 + codex-rs/codex-mcp/src/resource_origin.rs | 65 ++++++++++++++++++ codex-rs/codex-mcp/src/runtime.rs | 17 +++++ codex-rs/core/src/agent/control_tests.rs | 4 ++ codex-rs/core/src/session/mod.rs | 1 + .../session/rollout_reconstruction_tests.rs | 11 +++ codex-rs/core/src/session/session.rs | 16 ++++- codex-rs/core/src/session/tests.rs | 4 ++ codex-rs/history/src/lib.rs | 2 + codex-rs/history/src/rollout_payload.rs | 5 ++ codex-rs/history/src/tests.rs | 4 ++ codex-rs/protocol/src/mcp.rs | 24 +++++++ codex-rs/rollout/src/metadata_tests.rs | 1 + .../src/local/model_context_tests.rs | 1 + .../local/rollout_migration/rollback_plan.rs | 2 + .../src/local/rollout_migration_tests.rs | 52 ++++++++++---- .../thread-store/src/thread_metadata_sync.rs | 1 + .../resume_picker_transcript_preview_tests.rs | 3 + 22 files changed, 254 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 0b1cefd57afbde2fe4dcdb56eab85b2ca5b4192f..e5e6fc868326c2940aa7bf17250af037ebfac619 100644 GIT binary patch delta 17097 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%0A3+TP5MRl+iSgqtzR2o*`&#{EH4 z)e$Nx%cvtHRJ31XARoNmD+v`91=gnX|JfI1Dk9ZI0T}E`?b~`kWWVOt+hfq!u-&tL zV8uInEBS-0WR@Idk;SopEt$;KZE{{bAmjM5nV+VQp+ts9BK@WZI!FlaG>`Y|xXtL+ zxNRGf3DlbwgL8(KUP<8a5Xer2`O`g?76@fkT0xKymLtK<_9BVpNS8?#CIl@^JKjJJ zXj~FelWTj}FgMKHE3ns;lSm|zKQaqVy>d1*D9i=sz4&Cm@T2j6J(Yzrgutqv(fv7L zk}y+a8Z`P&7|HOK`nPR8x1tybq=Y-As%?WU9L>1&T!(G}IyVaex&kGxy`uW!FY*l* zX|ah8!GH%Aa`_FT0Q^4$Lz7=5lH73|EPr73dEnQ4M}S*c)yNVfj7!M-i_=R|Ogu~7 z761V1Zb$Fo((#CY$F*ap7Gj?HC9pDs9YDm}>Ad99L=o=gq!Y;qWeSt(gyI%kGL81~ zg^P9l5q=Q(qA+LK!8**t3I7XQX%2DvlRO>F2s=V8&R|T6cSPQks1#}E0j?mja6PSu z2{i7l1i4(f@G936_VovRe>ZC#*zi{+!$M|&uu>v^B>aDWD-mpEDE-9Q8Hj;_(yK=8 z4kM_+t|>Hr<~U;tOJFw{ymFBth&W^H&Yk(lTa4~_id!C*&ys|CLW}JKl0EgbX8#Rf z5Uu7K1Rs=}AJw41B#5$N)Z$V(mCOjb3;N}%0NH#)n_3A#n$=1O^$%JI5S}*ul34YK zB$zYBkKi+Z8JbF32v&!Z7E)tfn$P>SBrQzf{ghmZH>uiknR}MQy|h(v=PA0xjb?ie zfO;;m6k(dkF@gQQN>Tp$$B6IDmF~BquN1WguNoE zyrOY1ktDVBMCI-qy5C)evF>$^fjz*zV0hO>?Y*~~>H5c8e)AjhSX(y19ntjMa9yRY zI|unDVz#!&qId%kyHQBI(b=Pw?5?HmU_NQjE}U`wJlF#x*N{pBFb*Z&I3vYl_II;a zQo1dF$~!d2GpL*;Ew~sqJcyPBC3Qp=y$nS!z33;Go`=-fT7)R|0Ky{s5_ht0P7Fw* zPWt<-TfcZT{Z_uA@)AkaF54Ioi(CXH71}1cdu%O!^_a0LBoO4Rj70=1D_alzS%_s$ zy1k?lCCwZO>M|)6ggJ68*j?_e#8H_p7eT*&&z!=efN;thaICC4DJxa2EH+Mu($lKP zXKa#``T#EkYuh?T01|hn3t=ghQ5_Dw)E$QD3P082cCQZ#TX;T>hQY30PQ6O-0~^^h&YvL4O}YT z$k?#4gcPSiOMhuTjD!PKv_)aLEv=OQRzR0qF)I)q<31^^Ei{p<*{_ZW1H!!>?*duV ziABr5ncWD$2c^Gq=_Zl2Eb*w9B-;LeCL1d;QcbqmO!?#Dx_B*S=v=f9n_tT+9<7R0 z)#dA355LlToFohgRC6S^=pjJ0?p|47Yc@siW-5;Jj!HtXL@{T!$ACY}Nbp_DiCTWC z^zI?eODf$_&=ghobnRX4e1T4%bK~65qm*(+}O&VS@ ze_-{VI#%g#iH*jjo{;;X&eq$I=U9jVURHN&o#-}lyBCcAjdTTtgNde-IJBo!7<~Z6 zR$szuY+k0Bnr1!YR3r^M+in8xY3$kX58o{mP57 zYIlR23%Lv43i5#?=u<|j1tyQLga0X&C{2y*pl z?hG~7IstyW;(g+`?le%Txkctw^mX9B<-@>nEFK>P1YNHV@iVVGS~s14mB&-yv;=U) z<#j-ukj5Q~NWOKP4%2!BKByE8tFIhwObu82{TW0;FB9|s5;N4IQpKuzA$oJ_*+%Z6 zPpi5lL_Xu+NNk2hST(jo2z_!Dd;Zw}GrR$kERT7J!=s?kYkfT8XY%c*-l{{X-02g( zd|P=H0Dh-`|B~>BO0VjF5lebiGt0PO_;5|7;<^=xvpQLW85Y}o6U;?M+BuA*b*G>1 zJ1nrE4dJRNt}3FBhD5=*N;VfG`@ip8>L6&Qfiv9J2;k|L-^P0s1^mV5IXb< z>z=GPfL9B z^w)_zxJfFa)f~C$vdxzqsWEXI?7$k9+ z)%ANI$@f$Q-boHGA_ZoubmAdq*U|Zs-c90>QrPI%YbQ918J?K!h*h`G9xO#nRJtSpn0VW$a!$RsxTKvyVd#k$Rwy445Eck%ildem* zNP<^-8-!uuLydy;J~pQmRQ~O5{fTy3GOrh(mPy)jc>`#}eB)Hq!OKhFaM&C+wK61I zTf+cht-P>*Fd*=XzS0l3f7`W=|Bt~(mZK}NmZ_Tijf|iQvTS(s3sdxoFtjozz<)6X z4%~Hn2L;(G?N?(WXDUiO3R~N~JqK|l`w>y<>&hWk+MjW^PjDmQE0=X_nsDNtzMz6F z!_V|Mh-9(8((r};Rez;)fn#-}m*rN;a1c)Yf)wwAe*~5Hp8_3Laq` zwmoh9jHwJI55%DlFbQ| zUnau9MDtt{D@=F(XN?dFhUUJ=P#o4$x8gx83nOxo*zH6-#rIA)SEAx0kJf+W`253v zjc`y72oIO?@NBZ*{Y?blF{zdUTPIZSYF-&K2};_^E=yCX>8U{BQY@Y79#9(;$c?t} zSZ;%GiX>^Gme~fOEK)R9@}>MJV+7NM~S*W0UqVTg-S-jRyb9T2UsI9N)I+vPfh^rLLsI)rMlz5TOV zhmb|%Y&9o)A@>MHOP9R*fGktZjM<=Pa@!l{CbL)$rF$%!_$&krK3WM13v7iD8|g(s zt)Y`_Z&z+&Ikd&cJ#HIjW-BA7Bi|~6ILz*?iY`Iw2PyK>VUGPC_Dm0dq2a{V%D*2- zjaCy<9RVeou~7c9uX#m(@tT}H+q;>ILLR74kl(VgPP!hPysNx@rUB_2q}3>c4budE zFpE&d-A0%QYDiHY)Zv$uZ~5f$?}NUl#5KX%Bobx8#^D*IV?w>1ujk`yKS18LjR{~U zDX3HoX8;^YG?K9yiXQrZMicLbC|AvZr`PpPA~&Q#7d}BP6BLSw3?WTqgI~KjA0sd!p@@p_G+SW*Tm-6$|r~-_|x5B6S3fHn5PbRZBf&d95mh z;gq*FxVLYi2h5SefqV!^+s4*W6CC{hQF8)I19E)%osHtM=qyz{gB3!_ zyU*&PIT-?VxV9InYJ<^Tb^>G;$7Hog{LZ5Jt@q=^80QCn@ydd5w93+IHXO$66Wd&J zE+(z!9Wdtw11Th}W`fd@R?|8W62eC>@vkPQf`qWzed$RUo--huoy6QgfK;lrH&B(=Aa{l+CZ%h zr@75*E+GMb$8Kw0)5)JGU^XasRXb0TLh~97wkSaLSLQW=^<-Y-ZC!aNtm*kWxyWX( zldu|WlMa$BPPs6m?E8-=zGb!)6lVtAi3PX+OXRk=wY)P>>_TPt7LIVsmOIpLa|uGg zG?^^znAZ-(rM5Nu0e4UnN_c%|4gnMud@`6C>y7Mx;r8>oMlR?$+B+<6q+hS6~!n-|OY7yS>K!=*~0argo zE_9TQ(FCT2=}imAa?Z#@-WmhXr0Cq~PPCnJn+_>}+{?&dIh>X?^`MrPnzp|heB0v3 zB!umMF|loHEz&pQCa*vPD@_(w02sldnjbY}Q}S&ht>zo%N`qmF6#z8vH3<3QZPtdX-#;6Mhn1wi zF7)hiWEVNf^1vO4_j3Ku>DeF<`}6?o9ZB8Qnor+Y8VDxb^=7uq{9``OV0)lAFq|HL zC7&EFC=xZZi(=YvFcd;D1N7wOA_D)G+p0eKuH8gLPcH&!#=)?#!0PR)?R$|&Lj`w& zaxl^FRO`2cphEb}#qAzUZd^i0Y#^GYPPs~_9>4au%mH#T7^8)o@-0;Otyf9ZUz=uf zF-Lq6=uqvHmR7D@H5(8+E-{T}uxA2)!7P>b=~_h@_of3~uJe1T@s%Gwflf3PwJrd{ z#S|ED*#Y!7@^%nb3HQQv8sXqqV-LOcw}Q$6mGWAN_BrSTPXLt%gSA8*VV-l(IT=I^)$LcknFEm$FD8R2n%u7of)VnmOIsuUwW>=t5ifDWkugG0oJXPgA>k5J?f zGFhKbnqr3Y^N&@(b8ewSp>tDwv5rP~Lk#3mZYgj~AUJcvl6DZ(7W*gCW-ipC$L9{Y z;=7o|gTu{!a9wSTb9-8#TKc<^VMnZiit1c50s~+6c2?8HEnU0JPo;u?_`(|2YB2?s zNTp+v7&1ts3cRTCLo3K?P_Svf^w|!yNo5^err-cxE>tTv09K(BFp$drcUQ07t#2|; z4i+^2^|tF1-k|)t2BqVl|2R|h)-;g8ag;T;3~m`b^M?_f3%B=^CgS+XDC(YgBE!;l zQ+sGs55m&w=`gS;3_5Cm!4%J=1oR3z`AndP0vHfR{ZKuK`MtsD^}?O*bM<@QXz_S2 zw2i%!@2{c*h{9d=GM8mS5&>&vxA1`s@>V15)*Ac*EQfvy+8%*bM? z?~0?a45k#evXVg!Th$_VF^?B|>pjy~amn-^>CH?CD5%miZ*JXx=2VC`-o`OmaDInP zh?~k(Yz)e;yp;@!V+HOTK0Ny{lg~~NMH=ZcHI#%$w_rg#m%dgKGm(I^kPnxg-ma#gKB=-dPSQ*L)11weCR`j;rBJ!)7VSh-4Akgqbkx@WA( z;UBR!j>fgSxJ>1LW5&{|f4q(K@Qs_7%c#gE9|tr{49s1UCEj`3zS+yP{0il~6-@4da2f0GUGBTvg)*G4#%Sbk(yv$%N&&M1O#Mom<`xp5stc6IKw_*! zUzEyQdPx7hoSPk7X4IeSEdIiS>5g|gc1$!I&s1Q-hj>$J2q*%+e;|0f!^ndjZjvd^ z>%G-X-Rwq^QDb(umf@^td8b18&vdXpiy!pdTmJU}A<-|~l<@zEdL-s)J+4MJ-pxr? zW3=ECr0K23sCXa`Qv~^?(&GvC?8gvR3IF z#*j6U6zOAVEa(8wkeMVvZZh^R zW@>{)MJDx47gM8*|Kd>I)yl?A5<}I%P#36lZe{CKAx{DoWk2@$Z(BCuqO}R_+vqM6>^Q81NnNsme`8UIQx@UYrO?%a_@LyO2^XkChDe>CJc#|_|HK=|Y~qM57Z zoWMuf+LZJ?h|V0^%B1v$j)$g|37(S2!IS+N1fH&I+jFyY;Xf&q9~tC50f3fDj~c2(Af zq>hj|Jt?RUiaCYGtuh*5{cXWGSxIoia|_2PeXJyo(qP3(D0+tAO=2YgzGLO#qdh4h EQ9hKz7ytkO delta 16906 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 Result<()> { +async fn widget_reads_survive_history_modes_compaction_restarts_and_app_only_visibility() +-> Result<()> { let responses_server = responses::start_mock_server().await; let (apps_server_url, calls, apps_server_handle) = start_resource_apps_mcp_server().await?; calls.tools_enabled.store(true, Ordering::Relaxed); @@ -62,6 +65,22 @@ async fn widget_reads_survive_history_modes_restarts_and_app_only_visibility() - let mut model_responses = std::iter::repeat_n(response, 4) .flatten() .collect::>(); + model_responses.insert( + /*index*/ 4, + responses::sse(vec![ + responses::ev_response_created("widget-compaction"), + responses::ev_assistant_message("widget-summary", "The apps found matching lamps."), + responses::ev_completed("widget-compaction"), + ]), + ); + model_responses.insert( + /*index*/ 5, + responses::sse(vec![ + responses::ev_response_created("after-widget-compaction"), + responses::ev_assistant_message("widget-follow-up", "The lamps are still available."), + responses::ev_completed("after-widget-compaction"), + ]), + ); model_responses.push(responses::sse(vec![responses::ev_completed( "app-only-visibility", )])); @@ -161,6 +180,41 @@ async fn widget_reads_survive_history_modes_restarts_and_app_only_visibility() - ); } if !ephemeral { + if history_mode == ThreadHistoryMode::Paginated { + let compact_id = app_server + .send_thread_compact_start_request(ThreadCompactStartParams { + thread_id: thread.id.clone(), + }) + .await?; + let _: ThreadCompactStartResponse = + timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(compact_id)).await??; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let turn_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "What did the apps find?".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: TurnStartResponse = + timeout(DEFAULT_READ_TIMEOUT, app_server.read_response(turn_id)).await??; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; + for call_id in ["walmart-call", "best-buy-call"] { + let response = read_widget(&mut app_server, &thread.id, call_id).await?; + assert_eq!(response.origin_call_id.as_deref(), Some(call_id)); + } + } persistent_thread_ids.push(thread.id); } } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 5dbcba6eef..caafcc01f0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -165,6 +165,7 @@ async fn thread_resume_paginated_model_context_preserves_original_metadata() -> &RolloutItem::Compacted(CompactedItem { message: "compacted history".to_string(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: Some(1), first_window_id: None, previous_window_id: None, diff --git a/codex-rs/codex-mcp/src/resource_origin.rs b/codex-rs/codex-mcp/src/resource_origin.rs index 0a21c4c63e..9783185310 100644 --- a/codex-rs/codex-mcp/src/resource_origin.rs +++ b/codex-rs/codex-mcp/src/resource_origin.rs @@ -8,6 +8,8 @@ use codex_connectors::AppToolPolicyInput; use codex_protocol::ThreadId; use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::TurnItem; +use codex_protocol::mcp::McpResourceOrigin; +use codex_protocol::mcp::McpResourceOriginCheckpoint; use codex_protocol::protocol::EventMsg; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; @@ -37,6 +39,69 @@ pub(crate) struct ResourceOrigin { } impl ResourceOrigins { + pub(crate) fn checkpoint(&self) -> Option { + (!self.origins.is_empty()).then(|| McpResourceOriginCheckpoint { + origins: self + .origins + .iter() + .map(|origin| McpResourceOrigin { + call_id: origin.call_id.clone(), + turn_id: origin.turn_id.clone(), + tool: origin.tool.clone(), + connector_id: origin.connector_id.clone(), + link_id: origin.link_id.clone(), + uri: origin.uri.clone(), + ambiguous_account: origin.ambiguous_account, + }) + .collect(), + turns: self.turns.iter().cloned().collect(), + current_turn_id: self.current_turn_id.clone(), + }) + } + + pub(crate) fn restore_checkpoint(&mut self, checkpoint: &McpResourceOriginCheckpoint) { + if checkpoint.origins.len() > MAX_ORIGINS + || checkpoint.turns.len() > MAX_ORIGINS + || checkpoint + .turns + .iter() + .any(|turn_id| turn_id.len() > MAX_ORIGIN_BYTES) + || checkpoint + .current_turn_id + .as_ref() + .is_some_and(|turn_id| turn_id.len() > MAX_ORIGIN_BYTES) + { + *self = Self::default(); + return; + } + + let origins = checkpoint + .origins + .iter() + .map(|origin| ResourceOrigin { + call_id: origin.call_id.clone(), + turn_id: origin.turn_id.clone(), + tool: origin.tool.clone(), + connector_id: origin.connector_id.clone(), + link_id: origin.link_id.clone(), + uri: origin.uri.clone(), + ambiguous_account: origin.ambiguous_account, + }) + .collect::>(); + if origins.iter().any(|origin| { + origin.byte_len() > MAX_ORIGIN_BYTES || origin.connector_id.trim().is_empty() + }) { + *self = Self::default(); + return; + } + + *self = Self { + origins, + turns: checkpoint.turns.iter().cloned().collect(), + current_turn_id: checkpoint.current_turn_id.clone(), + }; + } + pub(crate) fn observe(&mut self, event: &EventMsg) { match event { EventMsg::TurnStarted(event) if event.turn_id.len() <= MAX_ORIGIN_BYTES => { diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 0aeff678ea..3dbfb460ed 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -28,6 +28,7 @@ use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::ClientMcpExtensions; +use codex_protocol::mcp::McpResourceOriginCheckpoint; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; @@ -196,6 +197,22 @@ impl McpRuntime { .observe(event); } + /// Captures bounded widget provenance for the next compaction checkpoint. + pub fn resource_origin_checkpoint(&self) -> Option { + self.resource_origins + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .checkpoint() + } + + /// Restores widget provenance retained by a compaction checkpoint. + pub fn restore_resource_origin_checkpoint(&self, checkpoint: &McpResourceOriginCheckpoint) { + self.resource_origins + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .restore_checkpoint(checkpoint); + } + /// Reads a widget through the current binding of the app tool that produced it. pub async fn read_resource_for_call( &self, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index f09a3293c1..bbd7d04fd8 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1249,6 +1249,7 @@ async fn spawn_agent_numeric_fork_from_compacted_paginated_parent_clamps_to_prov } .into(), ]), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1665,6 +1666,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { replacement_history: Some( replacement_history.into_iter().map(Into::into).collect(), ), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1839,6 +1841,7 @@ async fn spawn_agent_full_fork_restores_instructions_after_compaction_discards_p replacement_history: Some( replacement_history.into_iter().map(Into::into).collect(), ), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1968,6 +1971,7 @@ async fn spawn_agent_full_fork_legacy_compaction_rebuilds_child_instructions_onc RolloutItem::Compacted(CompactedItem { message: "legacy compacted summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 777b1c882c..1fe8942b84 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3334,6 +3334,7 @@ impl Session { let compacted_item = CompactedItem { message: metadata.message, replacement_history: Some(items.clone()), + mcp_resource_origins: self.services.mcp_runtime.resource_origin_checkpoint(), window_number: Some(metadata.window_number), first_window_id: Some(metadata.window_ids.first_window_id.to_string()), previous_window_id: metadata diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index d801e10320..1d9848511d 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -1009,6 +1009,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1069,6 +1070,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1138,6 +1140,7 @@ async fn reconstruct_history_prefers_compacted_window_over_session_meta() { RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: Some(2), first_window_id: Some(compacted_first_window_id.to_string()), previous_window_id: Some(compacted_previous_window_id.to_string()), @@ -1173,6 +1176,7 @@ async fn reconstruct_history_replays_world_state_from_latest_compaction_window() RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: Some(1), first_window_id: None, previous_window_id: None, @@ -1220,6 +1224,7 @@ async fn reconstruct_history_preserves_legacy_compaction_count_with_session_meta RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1247,6 +1252,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1282,6 +1288,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear RolloutItem::Compacted(CompactedItem { message: "legacy summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1384,6 +1391,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1548,6 +1556,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1798,6 +1807,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -1970,6 +1980,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(Vec::new()), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index f622dc0cf1..4a4d6dcfee 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1224,8 +1224,20 @@ impl Session { mcp_projection.config.prefix_mcp_tool_names, )); for item in initial_history.get_rollout_items() { - if let RolloutItem::EventMsg(event) = item { - mcp_runtime.observe_event(event); + match item { + RolloutItem::Compacted(compacted) => { + if let Some(checkpoint) = &compacted.mcp_resource_origins { + mcp_runtime.restore_resource_origin_checkpoint(checkpoint); + } + } + RolloutItem::EventMsg(event) => mcp_runtime.observe_event(event), + RolloutItem::SessionMeta(_) + | RolloutItem::ResponseItem(_) + | RolloutItem::InterAgentCommunication(_) + | RolloutItem::InterAgentCommunicationMetadata { .. } + | RolloutItem::TurnContext(_) + | RolloutItem::WorldState(_) + | RolloutItem::SecurityRiskScore(_) => {} } } let session_extension_data = diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f03b45952..dfde550e51 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2052,6 +2052,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history.clone()), + mcp_resource_origins: None, window_number: Some(42), first_window_id: Some(first_window_id.to_string()), previous_window_id: Some(previous_window_id.to_string()), @@ -3893,6 +3894,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .map(ResponseItemEnvelope::new) .collect(), ), + mcp_resource_origins: None, window_number: Some(7), first_window_id: Some(first_window_id.to_string()), previous_window_id: Some(previous_window_id.to_string()), @@ -11167,6 +11169,7 @@ async fn sample_rollout( rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: Some(window_number), first_window_id: Some(window_ids.first_window_id.to_string()), previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), @@ -11214,6 +11217,7 @@ async fn sample_rollout( rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: Some(window_number), first_window_id: Some(window_ids.first_window_id.to_string()), previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index f95a9d994e..23fc515d8e 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use codex_protocol::ThreadId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::mcp::McpResourceOriginCheckpoint; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -141,6 +142,7 @@ mod rollout_payload; pub struct CompactedItem { pub message: String, pub replacement_history: Option>, + pub mcp_resource_origins: Option, pub window_number: Option, pub first_window_id: Option, pub previous_window_id: Option, diff --git a/codex-rs/history/src/rollout_payload.rs b/codex-rs/history/src/rollout_payload.rs index 6d40c3d881..cc4621bf5a 100644 --- a/codex-rs/history/src/rollout_payload.rs +++ b/codex-rs/history/src/rollout_payload.rs @@ -4,6 +4,7 @@ use super::CodexHarnessMetadata; use super::CompactedItem; use super::EventMsg; use super::InterAgentCommunication; +use super::McpResourceOriginCheckpoint; use super::ResponseItem; use super::ResponseItemEnvelope; use super::RolloutItem; @@ -131,6 +132,8 @@ pub(super) struct CompactedItemWire<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] replacement_history_metadata: Option>>, #[serde(default, skip_serializing_if = "Option::is_none")] + mcp_resource_origins: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] window_number: Option, #[serde(default, skip_serializing_if = "Option::is_none")] first_window_id: Option>, @@ -166,6 +169,7 @@ impl<'a> From<&'a CompactedItem> for CompactedItemWire<'a> { .collect() }), replacement_history_metadata, + mcp_resource_origins: item.mcp_resource_origins.as_ref().map(Cow::Borrowed), window_number: item.window_number, first_window_id: item.first_window_id.as_deref().map(Cow::Borrowed), previous_window_id: item.previous_window_id.as_deref().map(Cow::Borrowed), @@ -229,6 +233,7 @@ impl TryFrom> for CompactedItem { Ok(Self { message: item.message.into_owned(), replacement_history, + mcp_resource_origins: item.mcp_resource_origins.map(Cow::into_owned), window_number, first_window_id: item.first_window_id.map(Cow::into_owned), previous_window_id: item.previous_window_id.map(Cow::into_owned), diff --git a/codex-rs/history/src/tests.rs b/codex-rs/history/src/tests.rs index 94f08c0ad8..fbd115fb38 100644 --- a/codex-rs/history/src/tests.rs +++ b/codex-rs/history/src/tests.rs @@ -188,6 +188,7 @@ fn compacted_replacement_history_stores_metadata_in_an_aligned_sidecar() -> Resu }, ResponseItemEnvelope::new(compaction_item.clone()), ]), + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -289,6 +290,7 @@ fn compacted_metadata_remains_compatible_with_legacy_response_item_readers() -> let compacted_line = serde_json::to_value(RolloutItem::Compacted(CompactedItem { message: "summary".to_string(), replacement_history: Some(vec![envelope]), + mcp_resource_origins: Some(McpResourceOriginCheckpoint::default()), window_number: None, first_window_id: None, previous_window_id: None, @@ -430,6 +432,7 @@ fn compacted_item_serializes_window_number_and_id() -> Result<()> { let item = CompactedItem { message: "summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: Some(3), first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()), previous_window_id: Some("019b3f6e-0000-7000-8000-000000000002".to_string()), @@ -462,6 +465,7 @@ fn compacted_item_migrates_legacy_numeric_window_id() -> Result<()> { CompactedItem { message: "summary".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: Some(3), first_window_id: None, previous_window_id: None, diff --git a/codex-rs/protocol/src/mcp.rs b/codex-rs/protocol/src/mcp.rs index 7012d2b9f1..2d97dccd20 100644 --- a/codex-rs/protocol/src/mcp.rs +++ b/codex-rs/protocol/src/mcp.rs @@ -16,6 +16,30 @@ pub const OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID: &str = "openai/standard-form- /// Extension ID for MCP App UI rendering. pub const MCP_APP_UI_EXTENSION_ID: &str = "io.modelcontextprotocol/ui"; +/// Bounded app-resource provenance retained across a compaction checkpoint. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct McpResourceOriginCheckpoint { + pub origins: Vec, + pub turns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_turn_id: Option, +} + +/// The original app, account, tool, and URI that authorize one widget read. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct McpResourceOrigin { + pub call_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + pub tool: String, + pub connector_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub link_id: Option, + pub uri: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ambiguous_account: bool, +} + /// Client extensions that must not be advertised to MCP servers. const MCP_CLIENT_ONLY_EXTENSION_IDS: [&str; 1] = [OPENAI_STANDARD_FORM_INPUT_EXTENSION_ID]; diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 58f8fbdeef..28a14eb2d6 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -207,6 +207,7 @@ fn builder_from_items_falls_back_to_filename() { let items = vec![RolloutItem::Compacted(CompactedItem { message: "noop".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, diff --git a/codex-rs/thread-store/src/local/model_context_tests.rs b/codex-rs/thread-store/src/local/model_context_tests.rs index 51b1fd69d9..7e83d8a74f 100644 --- a/codex-rs/thread-store/src/local/model_context_tests.rs +++ b/codex-rs/thread-store/src/local/model_context_tests.rs @@ -623,6 +623,7 @@ fn compacted(message: &str, replacement_history: Option>) -> 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, diff --git a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs index ceee507db8..9f9b0653e0 100644 --- a/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs +++ b/codex-rs/thread-store/src/local/rollout_migration/rollback_plan.rs @@ -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( diff --git a/codex-rs/thread-store/src/local/rollout_migration_tests.rs b/codex-rs/thread-store/src/local/rollout_migration_tests.rs index 741383267c..b188b35247 100644 --- a/codex-rs/thread-store/src/local/rollout_migration_tests.rs +++ b/codex-rs/thread-store/src/local/rollout_migration_tests.rs @@ -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) -> 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, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index d85dd3f7da..9a4a67916d 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -581,6 +581,7 @@ mod tests { let item = RolloutItem::Compacted(CompactedItem { message: "compacted".to_string(), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, diff --git a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs index d3246a14e4..cb8bb4c5b0 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview_tests.rs @@ -72,6 +72,7 @@ fn legacy_transcript_preview_scans_tail_across_compaction() { RolloutItem::Compacted(CompactedItem { message: String::from("summary that is not transcript text"), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -202,6 +203,7 @@ fn legacy_transcript_preview_falls_back_for_oversized_hidden_record() { RolloutItem::Compacted(CompactedItem { message: "x".repeat(MAX_LEGACY_TRANSCRIPT_PREVIEW_SCAN_BYTES), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None, @@ -228,6 +230,7 @@ fn legacy_transcript_preview_falls_back_when_scan_budget_is_exhausted() { let compacted = RolloutItem::Compacted(CompactedItem { message: "x".repeat(MAX_LEGACY_TRANSCRIPT_PREVIEW_SCAN_BYTES / 8), replacement_history: None, + mcp_resource_origins: None, window_number: None, first_window_id: None, previous_window_id: None,