diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index e21eb2e6ad..1469ca846f 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -140,23 +140,84 @@ fn summarize_subagent_status(status: &serde_json::Value) -> String { status.to_string() } +/// The per-row `route:` line prints the child's effective provider/model +/// routing from the optional typed `child_route` receipt (upstream +/// 0c03b5a81 carries it on every compact status row; `resolved_profile_id` +/// is the receipt's connection identity). The emitter bounds the receipt +/// itself, but this renderer must not depend on that: one unbounded route +/// value would eat the per-row budget that keeps the eight-row fleet +/// summary bounded. Every string field is previewed and the assembled line +/// is hard-guarded, so the row stays short whatever a producer sent. +const SUBAGENT_ROUTE_FIELD_PREVIEW_CHARS: usize = 64; +const SUBAGENT_ROUTE_LINE_MAX_CHARS: usize = 200; + +/// `None` when the receipt carries nothing printable (missing/empty fields, +/// a non-object non-string value) — the row then shows no route line rather +/// than a placeholder. +fn subagent_route_line(route: &serde_json::Value) -> Option { + if let Some(object) = route.as_object() { + let field = |key: &str| -> Option { + object + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| summarize_text(s, SUBAGENT_ROUTE_FIELD_PREVIEW_CHARS)) + }; + let provider = field("provider_id")?; + let model = field("model_id")?; + let mut line = format!(" route: {provider}/{model}"); + let source = field("route_source"); + let profile = field("resolved_profile_id"); + match (source, profile) { + (Some(source), Some(profile)) => { + line.push_str(&format!(" (source={source}, profile={profile})")); + } + (Some(source), None) => line.push_str(&format!(" (source={source})")), + (None, Some(profile)) => line.push_str(&format!(" (profile={profile})")), + (None, None) => {} + } + // Hard guard: field previews alone do not bound the assembled line. + return Some(summarize_text(&line, SUBAGENT_ROUTE_LINE_MAX_CHARS)); + } + // Defensive: a producer may serialize the receipt as a bare + // "provider/model" label; anything else shapeless is skipped. + let raw = route.as_str()?.trim(); + if raw.is_empty() { + return None; + } + Some(summarize_text( + &format!( + " route: {}", + summarize_text(raw, SUBAGENT_ROUTE_FIELD_PREVIEW_CHARS) + ), + SUBAGENT_ROUTE_LINE_MAX_CHARS, + )) +} + fn summarize_subagent_snapshot( snapshot: &serde_json::Value, index: usize, transcript_handle_fallback: Option<&str>, + child_route_fallback: Option<&serde_json::Value>, ) -> String { // Session projections (`SubAgentSessionProjection`) keep `transcript_handle` // on the outer envelope while the wrapped result row carries none, so the // handle is captured before unwrapping and handed down as a fallback: the // visible row prints the value the hint gate saw instead of a phantom. + // `child_route` mirrors that rule in reverse: compact fleet rows and the + // compact spawn receipt strip the `snapshot` wrapper and keep the receipt + // on the envelope, and a legacy projection can predate the wrapped row's + // own field — the wrapped value wins when both exist. let outer_transcript_handle = snapshot .get("transcript_handle") .and_then(transcript_handle_row_value); + let outer_child_route = snapshot.get("child_route"); if let Some(inner) = snapshot.get("snapshot") { let fallback = outer_transcript_handle .as_deref() .or(transcript_handle_fallback); - return summarize_subagent_snapshot(inner, index, fallback); + return summarize_subagent_snapshot(inner, index, fallback, outer_child_route); } let Some(obj) = snapshot.as_object() else { @@ -203,8 +264,17 @@ fn summarize_subagent_snapshot( .map(|s| summarize_text(s, 1_600)); let steps = obj.get("steps_taken").and_then(serde_json::Value::as_u64); let duration_ms = obj.get("duration_ms").and_then(serde_json::Value::as_u64); + // The wrapped row's receipt outranks the envelope fallback; absent or + // unprintable values render no line at all (route is optional). + let route_line = obj + .get("child_route") + .or(child_route_fallback) + .and_then(subagent_route_line); let mut lines = vec![format!("- {agent_id} ({agent_type}) status={status}")]; + if let Some(route_line) = route_line { + lines.push(route_line); + } if let Some(transcript_handle) = transcript_handle { lines.push(format!(" transcript: {transcript_handle}")); } @@ -407,7 +477,7 @@ fn compact_subagent_tool_result_for_context(tool_name: &str, raw: &str) -> Optio )); break; } - out.push_str(&summarize_subagent_snapshot(snapshot, idx + 1, None)); + out.push_str(&summarize_subagent_snapshot(snapshot, idx + 1, None, None)); out.push('\n'); } Some(out.trim_end().to_string()) @@ -655,6 +725,21 @@ pub(crate) fn compact_tool_result_for_route( return raw.to_string(); } + // A `read` result that already fit its byte budget carries a resume + // footer instead of mid-line truncation; compacting it again would + // discard content the budget deliberately kept. A result that somehow + // exceeded its declared budget still falls through to the ordinary + // limits below. + if output + .metadata + .as_ref() + .and_then(|metadata| metadata.get("read_budget_bytes")) + .and_then(serde_json::Value::as_u64) + .is_some_and(|budget| raw.len() as u64 <= budget) + { + return raw.to_string(); + } + if let Some(summary) = compact_subagent_tool_result_for_context(tool_name, raw) { return summary; } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index c616a5f3c2..f4c42b6127 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -17030,6 +17030,39 @@ fn evidence_bounded_preview_is_not_recompacted() { assert!(context.contains("full output at /tmp/art_call.txt")); } +#[test] +fn budgeted_read_result_is_not_truncated_a_second_time_by_the_context_compactor() { + // `read` bounds itself to a per-call byte budget and ends a + // budget-limited result with a footer naming the exact offset to + // continue from. The 12K context hard limit used to re-truncate that + // bounded result into a ~900-char snippet, discarding both the content + // and the continuation contract. + let content = format!( + "{}\n\n[Showing lines 1-51 of 200 (50KB limit). Use offset=52 to continue.]", + "r".repeat(40_000) + ); + let budgeted = ToolResult::success(content.clone()).with_metadata(json!({ + "evidence_routing": "inline", + "read_budget_bytes": content.len() + })); + let passed_through = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &budgeted); + assert_eq!(passed_through, content); + assert!(passed_through.contains("Use offset=52 to continue")); + + // The same bytes without a declared budget still take the ordinary path, + // which is what proves the metadata (not the tool name) did the work. + let unbudgeted = ToolResult::success(content.clone()); + let compacted = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &unbudgeted); + assert!(compacted.contains("output compacted to protect context")); + + // A result that overran its own declared budget is not exempt. + let overrun = ToolResult::success(content).with_metadata(json!({ + "read_budget_bytes": 1_000 + })); + let compacted_overrun = compact_tool_result_for_context("deepseek-v3.2-128k", "read", &overrun); + assert!(compacted_overrun.contains("output compacted to protect context")); +} + #[test] fn codex_tool_retention_uses_oauth_route_window_not_asmall_contract_model_window() { let content = "route-effective context\n".repeat(900); @@ -17507,6 +17540,150 @@ fn forkguard_subagent_projection_outer_handle_reaches_summary_row() { ); } +// Upstream 0c03b5a81 lets every compact status row carry the typed +// `child_route` receipt, but the fork row summarizer dropped the field: the +// unscoped fleet surface never told the model which provider/model each +// child actually ran on. The row renderer must print a short `route:` line +// when the receipt is present and stay silent when it is not — and stay +// bounded even when a producer hands it an oversized receipt. +#[test] +fn forkguard_fleet_summary_rows_carry_bounded_child_route() { + let long_label = "p".repeat(400); + let fleet = json!({ + "action": "status", + "count": 3, + "agents": [ + {"agent_id": "agent_aaaa1111", "agent_type": "explore", "status": "Running", + "assignment": {"objective": "Map the rendering path"}, + "child_route": { + "requested_type": "explore", + "resolved_profile_id": "conn-main", + "canonical_role": "general", + "provider_id": "atlas-main", + "model_id": "gpt-5", + "route_source": "requested_model", + "requested_reasoning": "medium", + "runtime_version": "1.0.0", + "runtime_build_sha": "abc123" + }}, + {"agent_id": "agent_bbbb2222", "agent_type": "test", "status": "Completed", + "result": "12 tests green", + "child_route": { + "provider_id": long_label, + "model_id": long_label, + "route_source": long_label, + "resolved_profile_id": long_label + }}, + {"agent_id": "agent_cccc3333", "agent_type": "general", "status": "Completed", + "result": "done"} + ] + }) + .to_string(); + let context = + compact_tool_result_for_context("deepseek-v4-pro", "agent", &ToolResult::success(fleet)); + + assert!( + context.contains(" route: atlas-main/gpt-5 (source=requested_model, profile=conn-main)"), + "a row with a child_route receipt must print its provider/model routing:\n{context}" + ); + let route_lines: Vec<&str> = context + .lines() + .filter(|line| line.trim_start().starts_with("route:")) + .collect(); + assert_eq!( + route_lines.len(), + 2, + "exactly the two rows that carry a receipt print a route line; a row \ + without one must not get an empty placeholder:\n{context}" + ); + for line in &route_lines { + assert!( + line.chars().count() <= 200, + "every route line must stay bounded:\n{line}" + ); + } + assert!( + route_lines[1].contains("..."), + "oversized route fields must be preview-truncated:\n{}", + route_lines[1] + ); +} + +// The addressed projection wraps the result row in a `snapshot` envelope; +// the route read must survive that unwrap (the wrapped `SubAgentResult` +// carries its own receipt), fall through to the envelope when the wrapped +// row predates the field, and tolerate a bare string label. +#[test] +fn forkguard_subagent_projection_child_route_reaches_summary_row() { + let envelope_route = json!({ + "provider_id": "atlas-main", + "model_id": "gpt-5", + "route_source": "requested_model" + }); + let wrapped = json!({ + "name": "scout", + "agent_id": "agent_aaaa1111", + "status": "Completed", + "child_route": envelope_route, + "snapshot": { + "agent_id": "agent_aaaa1111", + "agent_type": "explore", + "status": "Completed", + "result": "mapped the rendering path", + "child_route": { + "provider_id": "inner-provider", + "model_id": "inner-model", + "route_source": "fallback" + } + } + }) + .to_string(); + let context = + compact_tool_result_for_context("deepseek-v4-pro", "agent", &ToolResult::success(wrapped)); + assert!( + context.contains(" route: inner-provider/inner-model (source=fallback)"), + "the wrapped row's route must reach the visible summary:\n{context}" + ); + + let envelope_only = json!({ + "agent_id": "agent_aaaa1111", + "status": "Completed", + "child_route": envelope_route, + "snapshot": { + "agent_id": "agent_aaaa1111", + "agent_type": "explore", + "status": "Completed", + "result": "done" + } + }) + .to_string(); + let context = compact_tool_result_for_context( + "deepseek-v4-pro", + "agent", + &ToolResult::success(envelope_only), + ); + assert!( + context.contains(" route: atlas-main/gpt-5"), + "the envelope route must fall through when the wrapped row lacks one:\n{context}" + ); + + let string_route = json!({ + "agent_id": "agent_aaaa1111", + "status": "Completed", + "child_route": "atlas-main/gpt-5" + }) + .to_string(); + let context = compact_tool_result_for_context( + "deepseek-v4-pro", + "agent", + &ToolResult::success(string_route), + ); + assert!( + context.contains(" route: atlas-main/gpt-5"), + "a bare string route label must still print, bounded:\n{context}" + ); +} + // The hint names `transcript_handle`, so it must gate on rows the summarizer // actually renders: past the eighth snapshot the receipt truncates, and a // handle stranded on a truncated row would print no value at all — a phantom diff --git a/crates/tui/src/tools/file.rs b/crates/tui/src/tools/file.rs index 8ea2685359..182714aeed 100644 --- a/crates/tui/src/tools/file.rs +++ b/crates/tui/src/tools/file.rs @@ -804,9 +804,17 @@ impl ReadFileTool { // but no hash or read-before-edit ceremony reaches the lowercase // schema or result. context.note_file_read(&file_path); + // The exact byte size this result was self-bounded to: the read + // budget bounds the window content, and the continuation footer (or + // the single-line fallback) rides on top of it. Declaring the final + // payload size lets the context compactor honor `read_budget_bytes` + // (raw bytes <= budget) so an already-bounded read is never truncated + // a second time on its way into the conversation. + let budgeted_bytes = output.len(); Ok(RichToolResult::plain( ToolResult::success(output).with_metadata(json!({ - "evidence_routing": "inline" + "evidence_routing": "inline", + "read_budget_bytes": budgeted_bytes })), )) } @@ -1221,9 +1229,15 @@ fn render_line_window( // The file tool self-bounds at 50 KiB and carries its own continuation // contract (`next_start_line`), so the large-output spillover envelope // must never re-wrap a read result with a second, weaker truncation. + // `read_budget_bytes` names the byte size this rendered result was + // self-bounded to (the visible-bytes budget bounds the window; the + // `` wrapper and resume footer ride on top of it) so the context + // compactor passes it through instead of truncating it again. + let budgeted_bytes = output.len(); ToolResult::success(output).with_metadata(json!({ "evidence_routing": "inline", - "content_hash": content_hash + "content_hash": content_hash, + "read_budget_bytes": budgeted_bytes })) } diff --git a/crates/tui/src/tools/file/tests.rs b/crates/tui/src/tools/file/tests.rs index 22aa345934..9b176dd74e 100644 --- a/crates/tui/src/tools/file/tests.rs +++ b/crates/tui/src/tools/file/tests.rs @@ -94,6 +94,49 @@ async fn contract_read_reports_huge_first_line_with_exact_bash_fallback() { ); } +/// The `read` primitive self-bounds its output to an explicit byte budget and +/// ends budget-limited results with a resume footer. The context compactor's +/// 12K hard limit used to re-truncate those already-bounded results, +/// destroying the continuation contract; the declared `read_budget_bytes` +/// metadata is what exempts them. The legacy `read_file` reader carries the +/// same exemption (see `read_file_budget_truncated_result_declares_its_budget_ +/// and_survives_the_compactor` in `file/tests/tools.rs`). +#[tokio::test] +async fn contract_read_result_declares_its_budget_and_survives_the_compactor() { + let temporary = tempfile::tempdir().expect("tempdir"); + // 999-byte lines: 51 of them plus their separators fit the 50KiB budget + // (50,999 bytes), line 52 would not. The footer rides on top of the + // budget-bounded window. + let line = "z".repeat(999); + let content = std::iter::repeat_n(line.as_str(), 200) + .collect::>() + .join("\n"); + std::fs::write(temporary.path().join("big.txt"), &content).expect("fixture"); + let context = ToolContext::new(temporary.path()); + + let result = ReadFileTool::execute_contract_read(json!({"path": "big.txt"}), &context) + .await + .expect("budgeted read"); + assert!( + result.content.contains("Use offset=52 to continue"), + "{}", + result.content + ); + assert!(result.content.len() > 12_000); + let budget = result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("read_budget_bytes")) + .and_then(serde_json::Value::as_u64) + .expect("read_budget_bytes metadata on a budget-limited read"); + assert!(result.content.len() as u64 <= budget); + + // End to end: the compactor passes the budgeted read through untouched. + let compacted = + crate::core::engine::compact_tool_result_for_context("deepseek-v3.2-128k", "read", &result); + assert_eq!(compacted, result.content); +} + #[tokio::test] async fn contract_read_offset_oob_and_limit_continuation_match_contract() { let temporary = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tui/src/tools/file/tests/tools.rs b/crates/tui/src/tools/file/tests/tools.rs index 4b310f60ca..5ad4fc14d9 100644 --- a/crates/tui/src/tools/file/tests/tools.rs +++ b/crates/tui/src/tools/file/tests/tools.rs @@ -397,6 +397,51 @@ async fn read_file_byte_truncation_keeps_head_and_tail() { ); } +/// The legacy `read_file` reader self-bounds its rendered window to an +/// explicit byte budget and ends budget-limited results with a re-read +/// contract. The context compactor's 12K hard limit used to re-truncate those +/// already-bounded results, destroying that contract; the declared +/// `read_budget_bytes` metadata is what exempts them. The model-facing `read` +/// primitive carries the same exemption (see +/// `contract_read_result_declares_its_budget_and_survives_the_compactor` in +/// `file/tests.rs`). +#[tokio::test] +async fn read_file_budget_truncated_result_declares_its_budget_and_survives_the_compactor() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let file = tmp.path().join("legacy-big.txt"); + // ~80KiB of long lines: the rendered numbered window blows past the 16KiB + // byte budget and comes back head+tail truncated with its re-read note. + let body: String = (1..=200) + .map(|_| format!("{}\n", "z".repeat(400))) + .collect(); + fs::write(&file, &body).expect("write"); + + let result = ReadFileTool + .execute(json!({ "path": "legacy-big.txt" }), &ctx) + .await + .expect("execute"); + assert!(result.content.contains("[CONTENT TRUNCATED]")); + assert!(result.content.contains("[TRUNCATED]")); + assert!(result.content.len() > 12_000); + let budget = result + .metadata + .as_ref() + .and_then(|metadata| metadata.get("read_budget_bytes")) + .and_then(serde_json::Value::as_u64) + .expect("read_budget_bytes metadata on a budget-truncated read"); + assert!(result.content.len() as u64 <= budget); + + // End to end: the compactor passes the budgeted read through untouched. + let compacted = crate::core::engine::compact_tool_result_for_context( + "deepseek-v3.2-128k", + "read_file", + &result, + ); + assert_eq!(compacted, result.content); + assert!(compacted.contains("[TRUNCATED]")); +} + #[tokio::test] async fn read_file_clamps_max_lines_to_hard_cap() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 644d7d0077..3006cc2c7f 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -699,6 +699,13 @@ pub fn history_cells_from_message(msg: &Message) -> Vec { content: display.to_string(), }]; } + // Raw runtime handoffs have live tool/status receipts, not user cells. + // Keep their model-facing payload intact and filter only the display. + // (Fork exception: the MCP briefing/recovery handoffs above have already + // returned as system cells by decision, so they never reach this filter.) + if crate::runtime_handoff::is_internal_runtime_handoff(msg) { + return Vec::new(); + } let mut cells = Vec::new(); diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index 976f8f2ea3..07d011dcb7 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -2674,3 +2674,55 @@ fn forkguard_mcp_boot_handoffs_render_as_system_cells_not_user_turns() { assert_eq!(cells.len(), 1); assert!(matches!(cells[0], super::HistoryCell::System { .. })); } + +/// A restored background-shell completion is runtime control traffic: the +/// live tool/status receipts tell its story, so replayed history must not +/// project it as a user turn. The filter is display-only — the persisted and +/// model-facing message is untouched (the resume regression in +/// `tui::ui::tests` pins that byte-for-byte). Covers both the current +/// condensed and the legacy two-line provenance shapes, and contrasts with +/// ordinary composer text, which must survive the handoff filter. +#[test] +fn restored_background_shell_completions_yield_no_cells_but_user_text_survives() { + let envelope = concat!( + "\n", + "{\"task_id\":\"shell_1\",\"status\":\"Completed\",\"exit_code\":0}\n", + "", + ); + let turn_metas = [ + "\nInput provenance: shell_completion (non-authoritative)\n", + "\nInput provenance: shell_completion\nInput authority: non_authoritative\n", + ]; + for turn_meta in turn_metas { + let handoff = Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: envelope.to_string(), + cache_control: None, + }, + ContentBlock::Text { + text: turn_meta.to_string(), + cache_control: None, + }, + ], + }; + assert!( + super::history_cells_from_message(&handoff).is_empty(), + "a raw shell-completion handoff must not replay as any cell ({turn_meta})" + ); + } + + let prompt = super::history_cells_from_message(&Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "please continue".to_string(), + cache_control: None, + }], + }); + assert!(matches!( + prompt.as_slice(), + [HistoryCell::User { content }] if content == "please continue" + )); +} diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 57ef4a769d..669ea8dffc 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -6671,11 +6671,12 @@ fn apply_loaded_session_restores_the_window_title_override() { #[test] fn apply_loaded_session_never_restores_background_shell_event_as_composer_draft() { - let mut app = create_test_app(); // Literal persisted shape from the v0.9.4 resume report. Runtime events // use the user transport role for provider compatibility, but their // provenance and authority make them categorically different from input - // submitted by the person at the composer. + // submitted by the person at the composer. Restored history must not + // replay them as a user turn — the live tool/status receipts tell that + // story — while the persisted and model-facing messages stay untouched. let shell_completion = Message { role: Role::User, content: vec![ @@ -6703,20 +6704,94 @@ fn apply_loaded_session_never_restores_background_shell_event_as_composer_draft( }, ], }; - let session = saved_session_with_messages(vec![ - text_message("user", "please continue"), - text_message("assistant", "The corrected run is still in progress."), - shell_completion.clone(), - ]); + for condensed_provenance in [false, true] { + let mut app = create_test_app(); + let mut handoff = shell_completion.clone(); + if condensed_provenance { + handoff.content[1] = ContentBlock::Text { + text: + "\nInput provenance: shell_completion (non-authoritative)\n" + .to_string(), + cache_control: None, + }; + } + let session = saved_session_with_messages(vec![ + text_message("user", "please continue"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "plan-complete".to_string(), + name: "update_plan".to_string(), + input: serde_json::json!({ + "plan": [{"step": "Check the output", "status": "completed"}] + }), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "plan-complete".to_string(), + content: "Plan updated".to_string(), + is_error: None, + content_blocks: None, + }], + }, + text_message("assistant", "The corrected run is still in progress."), + handoff, + ]); + let saved_bytes = serde_json::to_vec(&session).expect("serialize saved session"); + let message_bytes = serde_json::to_vec(&session.messages).expect("serialize messages"); - apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session"); + apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session"); - assert!(app.input.is_empty()); - assert!(app.queued_draft.is_none()); - assert_eq!(app.api_messages.last(), Some(&shell_completion)); - assert!(app.history.iter().any(|cell| { - matches!(cell, HistoryCell::User { content } if content.contains("background_shell_completion")) - })); + assert!(app.input.is_empty()); + assert!(app.queued_draft.is_none()); + assert_eq!(serde_json::to_vec(&session).unwrap(), saved_bytes); + assert_eq!( + serde_json::to_vec(&app.api_messages).unwrap(), + message_bytes, + "the model-facing payload must stay byte-identical to the saved session" + ); + assert!( + matches!( + app.history.as_slice(), + [HistoryCell::User { content }, HistoryCell::Tool(_), HistoryCell::Assistant { .. }] + if content == "please continue" + ), + "the restored handoff must not appear as a user cell: {:?}", + app.history + ); + } +} + +#[test] +fn apply_loaded_session_keeps_user_authored_shell_event_lookalikes() { + // A person quoting the envelope — with no runtime provenance backing it — + // is ordinary composer input and must stay visible after a restore. + let literal = concat!( + "\n", + "{\"stdout_tail\":\"This is my example\"}\n\n", + "\nInput provenance: shell_completion (non-authoritative)\n", + ); + for user in [ + text_message("user", literal), + authoritative_user_message(literal), + ] { + let session = saved_session_with_messages(vec![user]); + let mut app = create_test_app(); + apply_loaded_session(&mut app, &mut Config::default(), &session).expect("restore session"); + assert_eq!(app.api_messages, session.messages); + assert!( + matches!( + app.history.as_slice(), + [HistoryCell::User { content }] if content == literal + ), + "a user-authored lookalike must stay a visible user cell: {:?}", + app.history + ); + } } #[test] @@ -23707,8 +23782,29 @@ fn backtrack_cut_index_skips_tool_result_user_messages() { cache_control: None, }], }, + // A restored background-shell completion rides in a user-role + // carrier but is runtime control traffic: backtrack must not + // count it as a prompt the user could return to. + Message { + role: Role::User, + content: vec![ + ContentBlock::Text { + text: "\n{\"task_id\":\"shell_1\",\"status\":\"Completed\"}\n\ + " + .into(), + cache_control: None, + }, + ContentBlock::Text { + text: "\nInput provenance: shell_completion (non-authoritative)\n" + .into(), + cache_control: None, + }, + ], + }, ]; - // depth 0 = cut at the last real user prompt ("second", idx 4). + // depth 0 = cut at the last real user prompt ("second", idx 4), not the + // shell-completion handoff trailing it at idx 5. assert_eq!(super::backtrack_api_cut_index(&msgs, 0), Some(4)); // depth 1 = cut at the first real user prompt ("first", idx 0) — NOT the // tool_result at idx 2 that a naive role=="user" count would have hit.