From 51945b23770d641c75ea929a1d5472c2ffb2bb7d Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 21 Sep 2026 17:28:18 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(tui):=20=E6=81=A2=E5=A4=8D=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E9=9A=90=E8=97=8F=E5=86=85=E9=83=A8=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6=20handoff=EF=BC=8C=E4=BF=9D=E7=95=99=20MCP=20system?= =?UTF-8?q?=20cell=20=E4=BE=8B=E5=A4=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 吸收上游 6362e1e84 的通用过滤:history_cells_from_message 在 restored checkpoint 分支之后接入 is_internal_runtime_handoff, background shell completion 等运行时 handoff 恢复后不再渲染为 User cell(fork 同样存在该 bug)。过滤仅作用于显示层,持久化与 模型侧 api_messages 逐字节不变。MCP briefing/recovery 在更早分支 已按 fork 决策返回 System cell,不会落入新过滤器,行为保持。 测试按上游语义改写并新增: - apply_loaded_session_never_restores_background_shell_event_as_composer_draft 覆盖 current/legacy 两种 provenance、保存与会话消息字节不变、 恢复后 cell 序列精确为 [User, Tool, Assistant]; - 新增 apply_loaded_session_keeps_user_authored_shell_event_lookalikes: 无 provenance 的用户手打 envelope 形似物仍可见; - 新增 restored_background_shell_completions_yield_no_cells_but_user_text_survives: 单元级钉住两种 provenance 隐藏、普通用户文本幸存; - backtrack 回归测试追加 handoff 不计为用户发言的断言。 红→绿:改写后的 resume 回归、单元级隐藏断言与 backtrack 断言在 改动前均失败,改动后通过;forkguard MCP system cell 测试全程通过。 验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;history::/ui::/runtime_handoff::/ session_manager::/session_peek::/compaction:: 共 924 项通过;全量 lib 11849 项通过(4 个 remote_control/runtime_threads 用例因共享 runtime 目录在并行下环境性失败,单独运行通过,与本次改动无关)。 Signed-off-by: asto18089 --- crates/tui/src/tui/history.rs | 7 ++ crates/tui/src/tui/history/tests.rs | 52 ++++++++++++ crates/tui/src/tui/ui/tests.rs | 126 ++++++++++++++++++++++++---- 3 files changed, 170 insertions(+), 15 deletions(-) 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. From c0f37c81cc0b5336d4c47fb89019f9d2e4bfeb78 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 21 Sep 2026 17:36:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(tui):=20read=20=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=8C=89=E9=A2=84=E7=AE=97=E8=87=AA=E9=99=90=E5=90=8E=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E4=B8=8A=E4=B8=8B=E6=96=87=E5=8E=8B=E7=BC=A9?= =?UTF-8?q?=E5=99=A8=E4=BA=8C=E6=AC=A1=E6=88=AA=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移植上游 e7f7c71e2 的 context 压缩层豁免守卫。read 工具按字节预算自我 截断并以页脚声明"从 offset 续读",但压缩器硬限 12,000 字符会把打满预算 的 read 结果再做一次 head/tail 压缩,内容被切、续读契约丢失。 - compact_tool_result_for_route:metadata 带 read_budget_bytes 且 raw 字节长度不超过预算时原样放行(位置与上游一致:evidence_available 早退之后、subagent 摘要器之前);超出自身预算仍走常规压缩。 - read 工具两条物理路径在受预算约束的成功结果上写入 read_budget_bytes: execute_contract_read(read 原语,50KiB 预算)与 render_line_window (read_file 遗留阅读器,16KiB 预算)。值取最终输出实际字节长度: 窗口预算约束的是内容,续读页脚与 包装叠加其上,只有按最终 payload 声明,守卫条件才对打满预算的结果成立。 - 测试:engine 层验证带 metadata 原样放行、无 metadata 与超预算仍压缩; file 层两条路径各验证打满预算的结果携带 metadata 且过压缩器后页脚 完整存活(端到端)。 验证:cargo fmt;cargo clippy -p codewhale-tui --lib -- -D warnings 通过; cargo test -p codewhale-tui --lib -- core::engine::tests tools::file 531 通过 0 失败;contract_read read_budget budgeted_read 过滤 7 通过。 改动前 3 个新测试均红(被二次压缩 / 缺 metadata)。 Signed-off-by: asto18089 --- crates/tui/src/core/engine/context.rs | 14 ++++++++ crates/tui/src/core/engine/tests.rs | 33 +++++++++++++++++ crates/tui/src/tools/file.rs | 18 ++++++++-- crates/tui/src/tools/file/tests.rs | 43 ++++++++++++++++++++++ crates/tui/src/tools/file/tests/tools.rs | 45 ++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index e21eb2e6ad..d967dbebd2 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -655,6 +655,20 @@ 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. Honor 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..3a8918a7e2 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); 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"); From 39757ba425317c554ebe078ec8f5a135eaff42bc Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 21 Sep 2026 17:54:12 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(tui):=20=E8=A1=8C=E6=91=98=E8=A6=81?= =?UTF-8?q?=E5=99=A8=E8=BE=93=E5=87=BA=E6=9C=89=E7=95=8C=E7=9A=84=20child?= =?UTF-8?q?=20=E8=B7=AF=E7=94=B1=E5=9B=9E=E6=89=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游 0c03b5a81 在 emitter 侧让每行 compact status 携带有界化的 typed child_route,fork 的 subagent 行摘要器此前丢弃该字段,unscoped fleet 监视面上 child 实际使用的 provider/model 路由到不了模型。 - summarize_subagent_snapshot 每行在 receipt 可打印时输出 route 行: provider/model,附 route_source 与 resolved_profile_id(连接身份); 字段缺失或形状不符时静默跳过,不打印空占位。 - 有界:各字符串字段 preview 截 64 字符,整行硬性守卫 200 字符,不 依赖 emitter 侧 compact_child_route 的 ≤1024 字节保证。 - 兼容三形状:对象 receipt、裸 "provider/model" 字符串、snapshot wrapper 包裹(wrapped 行优先,envelope 兜底,与 transcript_handle 的 fallback 规则一致)。 验证:cargo fmt -p codewhale-tui;cargo clippy -p codewhale-tui --lib -- -D warnings 通过;RUST_MIN_STACK=16777216 cargo test -p codewhale-tui --lib -- core::engine::tests 381 通过(新增 forkguard_fleet_summary_rows_carry_bounded_child_route 与 forkguard_subagent_projection_child_route_reaches_summary_row, 先红后绿)。 Signed-off-by: asto18089 --- crates/tui/src/core/engine/context.rs | 74 ++++++++++++- crates/tui/src/core/engine/tests.rs | 144 ++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index d967dbebd2..615b1042cc 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"); + if source.is_some() || profile.is_some() { + line.push_str(" (source="); + line.push_str(&source.unwrap_or_else(|| "-".to_string())); + if let Some(profile) = profile { + line.push_str(&format!(", profile={profile}")); + } + line.push(')'); + } + // 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()) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 3a8918a7e2..f4c42b6127 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -17540,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 From c0fdc9e5d1aae1c564f274e8a204de0fbfbc4841 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 21 Sep 2026 18:12:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?style:=20=E8=87=AA=E6=A3=80=E5=BE=AE?= =?UTF-8?q?=E8=B0=83=E2=80=94=E2=80=94=E5=AE=88=E5=8D=AB=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E6=94=B9=20fall-through=20=E8=A1=A8=E8=BF=B0=EF=BC=8Croute=20?= =?UTF-8?q?=E8=A1=8C=E7=BC=BA=E5=A4=B1=E6=AE=B5=E4=B8=8D=E5=86=8D=E6=89=93?= =?UTF-8?q?=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: asto18089 --- crates/tui/src/core/engine/context.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/core/engine/context.rs b/crates/tui/src/core/engine/context.rs index 615b1042cc..1469ca846f 100644 --- a/crates/tui/src/core/engine/context.rs +++ b/crates/tui/src/core/engine/context.rs @@ -169,13 +169,13 @@ fn subagent_route_line(route: &serde_json::Value) -> Option { let mut line = format!(" route: {provider}/{model}"); let source = field("route_source"); let profile = field("resolved_profile_id"); - if source.is_some() || profile.is_some() { - line.push_str(" (source="); - line.push_str(&source.unwrap_or_else(|| "-".to_string())); - if let Some(profile) = profile { - line.push_str(&format!(", profile={profile}")); + match (source, profile) { + (Some(source), Some(profile)) => { + line.push_str(&format!(" (source={source}, profile={profile})")); } - line.push(')'); + (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)); @@ -727,7 +727,8 @@ pub(crate) fn compact_tool_result_for_route( // 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. Honor the ordinary + // 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