From f14d5c0c2a9c92fd557d1499ee128bdac4b76bee Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 01:13:02 +0200 Subject: [PATCH 1/8] test: wait for settings acknowledgements and resolved paths - wait for the settings refresh to finish before the first edit key in every terminal test that opens settings - wait for an acknowledged save to close its editor before leaving settings in the hook and permission tests - share both waits through `wait_settings_ready` and `wait_settings_saved` - compare canonical paths for the local settings file so the config profile test passes where the temporary directory is a symlink --- tests/claude_config_dir.rs | 7 +++++-- tests/terminal_resize.rs | 36 +++++++++++++++++++++++++----------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude_config_dir.rs b/tests/claude_config_dir.rs index 6a11c8bc..3c59fcbc 100644 --- a/tests/claude_config_dir.rs +++ b/tests/claude_config_dir.rs @@ -34,9 +34,12 @@ fn config_commands_inspect_only_the_selected_profile() { assert_eq!(settings["document"]["model"], "isolated-model"); assert_eq!(preferences["path"], profile.join(".claude.json").to_string_lossy().as_ref()); assert_eq!(preferences["document"]["theme"], "isolated-theme"); + // The local path follows the resolved working directory, which differs from + // the temporary path where that is a symlink (macOS /var). assert_eq!( - local["path"], - project.join(".claude").join("settings.local.json").to_string_lossy().as_ref() + std::fs::canonicalize(local["path"].as_str().expect("local path")).expect("reported file"), + std::fs::canonicalize(project.join(".claude").join("settings.local.json")) + .expect("local file") ); assert_eq!(local["document"]["fastMode"], false); assert!(profile.join("settings.json").is_file()); diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 27d8ae83..7c035c2c 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -365,6 +365,23 @@ impl TerminalTest { self.wait_until(needle, |test| test.screen().contains(needle)); } + /// Opening settings starts a refresh, and settings ignore edits until it completes. + fn wait_settings_ready(&mut self, needle: &str) { + self.wait_until(&format!("refreshed settings showing {needle}"), |test| { + let screen = test.screen(); + screen.contains(needle) && !screen.contains("Refreshing settings") + }); + } + + /// An editor ignores keys until its save is acknowledged, which closes it + /// and takes its `draft` off the screen. + fn wait_settings_saved(&mut self, saved: &str, draft: &str) { + self.wait_until(&format!("saved settings showing {saved}"), |test| { + let screen = test.screen(); + screen.contains(saved) && !screen.contains(draft) + }); + } + fn wait_setting(&mut self, file: &str, pointer: &str, expected: Option<&Value>) { self.wait_until(&format!("persisted {pointer}"), |test| { std::fs::read(test.temp.path().join(file)) @@ -610,7 +627,7 @@ fn tips_setting_preserves_immediate_activity_and_heading_through_real_config_sav .to_owned(); for (index, enabled) in [true, false, true].into_iter().enumerate() { test.submit("/config", "/config"); - test.wait_screen("Show tips"); + test.wait_settings_ready("Show tips"); if index == 0 { test.send(b"\x1b[B\x1b[B"); // Language -> Reduce motion -> Show tips. } @@ -1003,7 +1020,7 @@ fn fullscreen_resize_and_repeated_return_preserve_chat_and_next_submission() { for (rows, cols) in [(55, 120), (25, 61), (38, 87)] { test.submit("/config", "/config"); test.wait_screen("Saved in User"); - test.wait_screen("Description:"); + test.wait_settings_ready("Description:"); assert!(test.output.lock().expect("output lock").parser.screen().alternate_screen()); test.send(b" "); test.wait_screen("Enter save"); @@ -1038,7 +1055,7 @@ fn structured_settings_editor_adds_individual_rules_and_preserves_them_on_resize test.submit("go", "go"); test.wait_journal("reply-held"); test.submit("/permissions", "/permissions"); - test.wait_screen("Permissions: deny rules"); + test.wait_settings_ready("Permissions: deny rules"); test.send(b" "); test.wait_screen("Ctrl+S save"); test.send(b"a"); @@ -1054,7 +1071,7 @@ fn structured_settings_editor_adds_individual_rules_and_preserves_them_on_resize test.commands("mutate_setting")[0]["mutation"]["value"], serde_json::json!(["Read(./.env)", "Bash(git push *)"]) ); - test.wait_screen("2 items"); + test.wait_settings_saved("2 items", "Bash(git push *)"); let document: Value = serde_json::from_str( &std::fs::read_to_string(test.temp.path().join("profile/settings.json")) .expect("saved file"), @@ -1084,11 +1101,7 @@ fn guided_hook_creation_survives_resize_and_saves_a_complete_hook_with_existing_ test.submit("go", "go"); test.wait_journal("reply-held"); test.submit("/hooks", "/hooks"); - // Opening settings starts a refresh, and settings ignore keys until it completes. - test.wait_until("refreshed hook settings", |test| { - let screen = test.screen(); - screen.contains("Definitions") && !screen.contains("Refreshing settings") - }); + test.wait_settings_ready("Definitions"); test.send(b"\r"); test.wait_screen("Stop"); test.send(b"a"); @@ -1121,6 +1134,7 @@ fn guided_hook_creation_survives_resize_and_saves_a_complete_hook_with_existing_ assert_eq!(saved["hooks"], expected); assert_eq!(saved["unrelated"], "keep"); assert_eq!(saved["permissions"]["defaultMode"], "default"); + test.wait_settings_saved("2 entries", "npm run lint"); test.send(b"\x1b"); test.wait_screen("streamed line 8"); test.assert_prompts(&["go"]); @@ -1416,7 +1430,7 @@ fn saved_auto_scroll_off_holds_new_output_until_the_user_returns_live() { test.submit("Saved scroll test", "Saved scroll test"); test.wait_journal("reading-barrier"); test.submit("/config", "/config"); - test.wait_screen("Auto-scroll"); + test.wait_settings_ready("Auto-scroll"); test.send(b"\x1b[C"); // On -> Off at the selected user scope. test.wait_setting("profile/settings.json", "/autoScrollEnabled", Some(&Value::Bool(false))); test.send(b"\x1b"); @@ -1486,7 +1500,7 @@ fn notifications_follow_focus_saved_categories_and_sdk_delivery_provenance_in_a_ }); assert_eq!(notification_bells(&test), 1); test.submit("/config", "/config"); - test.wait_screen("Notification method"); + test.wait_settings_ready("Notification method"); test.send(b"\x1b[B\x1b[B"); // Pass turn completion and select proactive alerts. test.send(b" "); // On -> Off, immediate acknowledged save. test.wait_setting( From 12f8afa7bdd401550dc254f7a26c778c4eae1ad8 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 13:54:17 +0200 Subject: [PATCH 2/8] test(tui): synchronize terminal regressions with applied outcomes - await composer drafts, fresh turn output, and complete scrollback - correlate resize waits with logged draw dimensions - distinguish recorded auth input from child readiness - remove the obsolete source-pattern logging test - extend bounded waits for loaded CI runners --- .../terminal_runtime/fullscreen_session.rs | 12 +- tests/fixtures/fake-claude.rs | 2 +- tests/logging_contract.rs | 79 --- tests/terminal_resize.rs | 468 +++++++++++------- 4 files changed, 290 insertions(+), 271 deletions(-) delete mode 100644 tests/logging_contract.rs diff --git a/src/app/terminal_runtime/fullscreen_session.rs b/src/app/terminal_runtime/fullscreen_session.rs index a4ee2224..f9b8749f 100644 --- a/src/app/terminal_runtime/fullscreen_session.rs +++ b/src/app/terminal_runtime/fullscreen_session.rs @@ -33,9 +33,17 @@ pub(super) fn draw_fullscreen_surface_frame( terminal: &mut Terminal, app: &mut App, ) -> anyhow::Result<()> { - terminal + let area = terminal .draw(|frame| crate::ui::render_fullscreen_surface(frame, app)) - .map(|_| ()) + .map(|frame| frame.area) .map_err(|err| anyhow!("failed to draw fullscreen surface: {err}"))?; + tracing::debug!( + target: crate::logging::targets::APP_RENDER, + event_name = "fullscreen_surface_draw", + message = "fullscreen surface repainted", + outcome = "success", + terminal_width = area.width, + terminal_height = area.height, + ); Ok(()) } diff --git a/tests/fixtures/fake-claude.rs b/tests/fixtures/fake-claude.rs index 933319b5..532797f5 100644 --- a/tests/fixtures/fake-claude.rs +++ b/tests/fixtures/fake-claude.rs @@ -14,7 +14,7 @@ fn main() -> io::Result<()> { let mut input = String::new(); io::stdin().read_line(&mut input)?; std::fs::write(profile.join("auth-input"), input)?; - println!("AUTH_CHILD_READ"); + println!("AUTH_INPUT_RECORDED"); io::stdout().flush()?; while !profile.join("auth-release").exists() { std::thread::sleep(Duration::from_millis(20)); diff --git a/tests/logging_contract.rs b/tests/logging_contract.rs deleted file mode 100644 index 9c8b6502..00000000 --- a/tests/logging_contract.rs +++ /dev/null @@ -1,79 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Copyright 2025 Simon Peter Rothgang - -use std::fs; -use std::path::{Path, PathBuf}; - -fn collect_source_files(root: &Path, files: &mut Vec) { - let Ok(entries) = fs::read_dir(root) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - collect_source_files(&path, files); - continue; - } - let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else { - continue; - }; - if matches!(ext, "rs" | "ts") { - files.push(path); - } - } -} - -fn logging_source_files() -> Vec { - let mut files = Vec::new(); - collect_source_files(Path::new("src"), &mut files); - collect_source_files(Path::new("agent-sdk/src/bridge"), &mut files); - files -} - -#[test] -fn legacy_logging_markers_and_bridge_console_calls_are_removed() { - let mut failures = Vec::new(); - let banned_rust_markers = [ - "legacy_bridge_stderr_line", - "logPermissionDebug", - "RENDER_SCROLLED", - "RENDER_CULLED", - "RENDER_VISIBLE_PREVIEW", - "[sdk error]", - "[sdk warn]", - "[perm debug]", - ]; - - for path in logging_source_files() { - let Ok(text) = fs::read_to_string(&path) else { - failures.push(format!("failed to read {}", path.display())); - continue; - }; - - if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - for marker in banned_rust_markers { - if text.contains(marker) { - failures.push(format!( - "{} contains banned legacy logging marker `{marker}`", - path.display() - )); - } - } - } - - if path.extension().and_then(|ext| ext.to_str()) == Some("ts") { - for console_pattern in - ["console.error(", "console.warn(", "console.log(", "console.debug("] - { - if text.contains(console_pattern) { - failures.push(format!( - "{} contains banned bridge console logging `{console_pattern}`", - path.display() - )); - } - } - } - } - - assert!(failures.is_empty(), "legacy logging patterns remain:\n{}", failures.join("\n")); -} diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 7c035c2c..8505b110 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -15,6 +15,9 @@ const TALL_ROWS: u16 = 55; const RESIZES: u32 = 200; const SCROLLBACK_ROWS: usize = 10_000; const OWNED_REGION_ERROR: &str = "refusing to clear outside inline chat owned region"; +// A liveness bound for outcomes that must happen. It only limits how long a +// broken build takes to fail, so it is sized for a loaded CI runner. +const WAIT_LIMIT: Duration = Duration::from_secs(60); type PtyWriter = Arc>>; struct CapturedTerminal { @@ -148,6 +151,10 @@ struct TerminalTest { journal: PathBuf, release_file: PathBuf, temp: tempfile::TempDir, + /// The screen when the test last started acting. A needle found here was + /// not produced by that action. + before_action: String, + settled: bool, } #[allow(clippy::expect_used, clippy::panic)] @@ -187,7 +194,7 @@ impl TerminalTest { command.arg("--no-update-check"); command.arg("--log-file"); command.arg(temp.path().join("runtime.log")); - command.env("RUST_LOG", "warn,app.lifecycle=debug,bridge.protocol=debug"); + command.env("RUST_LOG", "warn,app.lifecycle=debug,app.render=debug,bridge.protocol=debug"); command.arg("--dir"); command.arg(&project); for arg in args { @@ -249,7 +256,16 @@ impl TerminalTest { let child = TestChild { process, writer: Arc::clone(&writer) }; drop(pair.slave); let output = capture_output(reader, writer); - let mut test = Self { child, master: pair.master, output, journal, release_file, temp }; + let mut test = Self { + child, + master: pair.master, + output, + journal, + release_file, + temp, + before_action: String::new(), + settled: true, + }; test.wait_screen("Trust this project"); test.send(b"y"); if has_initial_prompt { @@ -263,13 +279,21 @@ impl TerminalTest { test } - fn send(&self, bytes: &[u8]) { + fn mark_action(&mut self) { + if self.settled { + self.before_action = self.screen(); + self.settled = false; + } + } + + fn send(&mut self, bytes: &[u8]) { + self.mark_action(); let mut writer = self.child.writer.lock().expect("writer lock"); writer.write_all(bytes).expect("write to pty"); writer.flush().expect("flush pty"); } - fn paste(&self, text: &str) { + fn paste(&mut self, text: &str) { self.send(format!("\x1b[200~{text}\x1b[201~").as_bytes()); } @@ -290,32 +314,58 @@ impl TerminalTest { rows } - fn assert_completed_reply_in_scrollback(&self, lines: u16) { + fn missing_reply_row(&self, lines: u16) -> Option { let rows = self.transcript_rows(); - for line in 1..=lines { + (1..=lines).find(|line| { let suffix = format!("streamed line {line}"); + !rows.iter().any(|row| row.trim_end().ends_with(&suffix)) + }) + } + + /// History reaches the terminal in batches, so the whole reply is awaited + /// rather than asserted against whichever batch has arrived. + fn wait_completed_reply_in_scrollback(&mut self, lines: u16) { + let started = Instant::now(); + while let Some(line) = self.missing_reply_row(lines) { assert!( - rows.iter().any(|row| row.trim_end().ends_with(&suffix)), - "completed reply row {line} disappeared from scrollback:\n{}\nTranscript:\n{}", + started.elapsed() < WAIT_LIMIT, + "completed reply row {line} is missing from scrollback:\n{}\nTranscript:\n{}", self.diagnostics(), - rows.join("\n"), + self.transcript_rows().join("\n"), ); + std::thread::sleep(Duration::from_millis(20)); } + self.settled = true; } + /// Returns once the app has drawn at the new size. The dimensions must + /// differ from the current ones, or an earlier draw would satisfy the wait. fn resize_and_wait_for_draw(&mut self, rows: u16, cols: u16) { + let current = self.output.lock().expect("output lock").parser.screen().size(); + assert_ne!(current, (rows, cols), "a draw at the current size proves no resize"); let log_path = self.temp.path().join("runtime.log"); let previous_length = std::fs::read(&log_path).expect("runtime log").len(); self.resize(rows, cols); self.wait_until("draw at the new terminal dimensions", |test| { test.runtime_records_since(previous_length).iter().any(|record| { - record["event_name"] == "inline_chat_viewport_draw" + (record["event_name"] == "inline_chat_viewport_draw" + || record["event_name"] == "fullscreen_surface_draw") && record["terminal_height"] == rows && record["terminal_width"] == cols }) }); } + /// Resizes, waits for the app to draw at the new size, and then requires + /// everything in `kept` to be on that screen. + fn resize_keeping(&mut self, rows: u16, cols: u16, kept: &[&str]) -> String { + self.resize_and_wait_for_draw(rows, cols); + self.wait_for(&format!("{kept:?} after resizing to {rows}x{cols}"), |test| { + let screen = test.screen(); + kept.iter().all(|needle| screen.contains(needle)).then_some(screen) + }) + } + fn runtime_records_since(&self, offset: usize) -> Vec { let log = std::fs::read_to_string(self.temp.path().join("runtime.log")).unwrap_or_default(); log.get(offset..) @@ -338,17 +388,19 @@ impl TerminalTest { ) } - fn wait_until(&mut self, description: &str, predicate: impl Fn(&Self) -> bool) { + /// Polls `probe` until it yields a value twice in a row, so a transient + /// partial paint is not sufficient to advance the test. + fn wait_for(&mut self, description: &str, probe: impl Fn(&Self) -> Option) -> T { let started = Instant::now(); let mut observed = false; - while started.elapsed() < Duration::from_secs(20) { - // Require two observations, so a transient partial paint is not - // sufficient to advance the test. - let matches = predicate(self); - if matches && observed { - return; + while started.elapsed() < WAIT_LIMIT { + match probe(self) { + Some(value) if observed => { + self.settled = true; + return value; + } + value => observed = value.is_some(), } - observed = matches; assert!( self.child.process.try_wait().expect("poll child").is_none(), "child exited waiting for {description}:\n{}", @@ -361,8 +413,69 @@ impl TerminalTest { panic!("timed out waiting for {description}:\n{}", self.diagnostics()); } + fn wait_until(&mut self, description: &str, predicate: impl Fn(&Self) -> bool) { + self.wait_for(description, |test| predicate(test).then_some(())); + } + + /// Waits for `needle` to appear as a result of the last action. fn wait_screen(&mut self, needle: &str) { - self.wait_until(needle, |test| test.screen().contains(needle)); + self.wait_screen_with(needle, &[]); + } + + /// Waits for one screen that shows the new `needle` together with + /// everything in `kept`, and returns that screen. + fn wait_screen_with(&mut self, needle: &str, kept: &[&str]) -> String { + assert!( + !self.before_action.contains(needle), + "{needle:?} was on screen before the test acted, so it cannot show the outcome:\n{}", + self.before_action + ); + self.wait_for(&format!("{needle:?} with {kept:?}"), |test| { + let screen = test.screen(); + (screen.contains(needle) && kept.iter().all(|kept| screen.contains(kept))) + .then_some(screen) + }) + } + + /// The status leaves `[READY]` when a prompt is submitted and returns only + /// when its turn completes, so `[READY]` beside output from that turn shows + /// the app applied the completion. + fn wait_turn_finished(&mut self, turn_output: &str) -> String { + self.wait_screen_with(turn_output, &["[READY]"]) + } + + /// Returns the first streamed row shown while the viewport is pinned. + fn wait_pinned_output(&mut self) -> String { + self.wait_screen_with("Reading output", &["streamed line"]) + .lines() + .find(|line| line.contains("streamed line")) + .expect("visible output anchor") + .trim() + .to_owned() + } + + fn release_auth_child(&mut self) { + self.mark_action(); + std::fs::write(self.temp.path().join("profile/auth-release"), b"continue") + .expect("release auth child"); + } + + /// Output written while the app exits is read after the exit is observed. + fn wait_final_output(&self, needle: &str) -> String { + let started = Instant::now(); + loop { + let raw = + String::from_utf8_lossy(&self.output.lock().expect("output lock").raw).into_owned(); + if raw.contains(needle) { + return raw; + } + assert!( + started.elapsed() < WAIT_LIMIT, + "missing {needle:?} in final output: {}", + tail(&raw, 4000) + ); + std::thread::sleep(Duration::from_millis(20)); + } } /// Opening settings starts a refresh, and settings ignore edits until it completes. @@ -433,12 +546,36 @@ impl TerminalTest { } fn submit(&mut self, text: &str, visible: &str) { + let log_offset = + std::fs::read(self.temp.path().join("runtime.log")).expect("runtime log").len(); self.paste(text); - self.wait_screen(visible); + // The overview, tips, and transcript can already mention the command. + // Check the editor region from a subsequent draw. ConPTY's native cursor + // can remain on the spinner instead of the editor. + self.wait_until(&format!("composer contains {visible:?}"), |test| { + let Some(draw) = test + .runtime_records_since(log_offset) + .into_iter() + .rev() + .find(|record| record["event_name"] == "inline_chat_viewport_draw") + else { + return false; + }; + let Some(top) = draw["editor_top"].as_u64().and_then(|top| usize::try_from(top).ok()) + else { + return false; + }; + let Some(height) = + draw["editor_height"].as_u64().and_then(|height| usize::try_from(height).ok()) + else { + return false; + }; + test.screen().lines().skip(top).take(height).any(|line| line.contains(visible)) + }); self.submit_draft(); } - fn submit_draft(&self) { + fn submit_draft(&mut self) { // Windows falls back to key-burst paste detection. Its 250 ms trailing // Enter suppression deliberately treats an immediate CR as paste text. std::thread::sleep(Duration::from_millis(500)); @@ -446,12 +583,19 @@ impl TerminalTest { } fn resize(&mut self, rows: u16, cols: u16) { + self.mark_action(); self.output.lock().expect("output lock").parser.screen_mut().set_size(rows, cols); self.master.resize(pty_size(rows, cols)).expect("resize pty"); } - fn release(&self) { - std::fs::write(&self.release_file, b"continue").expect("release fixture barrier"); + fn release(&mut self) { + self.signal("continue"); + } + + /// Moves the scripted bridge past its current barrier. + fn signal(&mut self, step: &str) { + self.mark_action(); + std::fs::write(&self.release_file, step).expect("signal fixture barrier"); } fn shutdown(&mut self) { @@ -527,12 +671,13 @@ fn auth_child_owns_stdin_and_output_and_returns_a_resized_usable_terminal() { assert_eq!(after, before, "TUI wrote while auth child owned the terminal"); test.resize(25, 61); test.send(b"child_only_407\r"); - test.wait_screen("AUTH_CHILD_READ"); + // The child records its input before it prints this marker. + test.wait_screen("AUTH_INPUT_RECORDED"); let profile = test.temp.path().join("profile"); let received = std::fs::read_to_string(profile.join("auth-input")).expect("child stdin record"); assert_eq!(received.trim_end_matches(['\r', '\n']), "child_only_407"); - std::fs::write(profile.join("auth-release"), b"continue").expect("release auth child"); + test.release_auth_child(); if mode == "failure" { test.wait_screen("/login failed (exit code: 7)"); } @@ -553,8 +698,7 @@ fn auth_child_owns_stdin_and_output_and_returns_a_resized_usable_terminal() { fn auth_spawn_failure_restores_terminal_ownership_and_input() { let mut test = TerminalTest::start_with_auth("stream", 3, Some("spawn-error")); test.submit("/login", "/login"); - test.wait_screen("Failed to run claude auth login"); - test.wait_screen("[READY]"); + test.wait_screen_with("Failed to run claude auth login", &["[READY]"]); test.resize(25, 61); test.resize(38, 87); test.send(b"AFTER_SPAWN_ERROR"); @@ -582,8 +726,7 @@ fn fatal_bridge_exit_during_auth_stops_the_owned_child_and_finishes_shutdown() { assert!(!status.success()); assert!(!test.temp.path().join("profile/auth-input").exists()); test.assert_prompts(&[]); - let output = test.output.lock().expect("output lock"); - assert!(String::from_utf8_lossy(&output.raw).contains("exited before completing the protocol")); + test.wait_final_output("exited before completing the protocol"); } #[test] @@ -596,11 +739,13 @@ fn concurrent_resize_typing_and_paste_deliver_every_prompt_exactly_once() { test.send(typed.as_bytes()); test.resize(38, 87); test.paste(" + PASTE 界 🦀"); - test.wait_screen("+ PASTE 界 🦀"); + // Earlier prompts stay in the transcript, so only this prompt's own + // text shows that the composer holds the whole draft. + let draft = format!("{typed} + PASTE 界 🦀"); + test.wait_screen(&draft); test.submit_draft(); - test.wait_screen(&format!("reply {} started", index + 1)); - test.wait_screen("[READY]"); - expected.push(format!("{typed} + PASTE 界 🦀")); + test.wait_turn_finished(&format!("reply {} started", index + 1)); + expected.push(draft); test.assert_prompts(&expected.iter().map(String::as_str).collect::>()); } test.shutdown(); @@ -645,8 +790,8 @@ fn tips_setting_preserves_immediate_activity_and_heading_through_real_config_sav && screen.lines().any(|line| line.trim_end() == activity_header) && screen.contains("│ Tip:") == enabled }); - test.resize(25, 61); - test.resize(SHORT_ROWS, COLS); + test.resize_and_wait_for_draw(25, 61); + test.resize_and_wait_for_draw(SHORT_ROWS, COLS); test.wait_until("resize retains activity and heading independently of tips", |test| { let screen = test.screen(); screen.lines().filter(|line| line.trim() == "Claude").count() == 1 @@ -654,22 +799,21 @@ fn tips_setting_preserves_immediate_activity_and_heading_through_real_config_sav && screen.contains("│ Tip:") == enabled }); } - std::fs::write(&test.release_file, "requires_action").expect("wait boundary"); + test.signal("requires_action"); test.wait_until("waiting hides activity and its empty heading", |test| { let screen = test.screen(); !screen.lines().any(|line| line.trim() == "Claude") && !screen.contains(&activity_header) && !screen.contains("│ Tip:") }); - std::fs::write(&test.release_file, "running").expect("resume boundary"); - test.wait_screen("│ Tip:"); - assert_eq!(test.screen().lines().filter(|line| line.trim() == "Claude").count(), 1); - assert!(test.screen().contains(&activity_header)); - std::fs::write(&test.release_file, "done").expect("finish without output"); - test.wait_screen("[READY]"); + test.signal("running"); + let resumed = test.wait_screen_with("│ Tip:", &[&activity_header]); + assert_eq!(resumed.lines().filter(|line| line.trim() == "Claude").count(), 1); + test.signal("done"); test.wait_until("completion removes the temporary heading", |test| { let screen = test.screen(); - !screen.lines().any(|line| line.trim() == "Claude") + screen.contains("[READY]") + && !screen.lines().any(|line| line.trim() == "Claude") && !screen.contains(&activity_header) && !screen.contains("│ Tip:") }); @@ -694,9 +838,7 @@ fn activity_stays_above_queue_and_out_of_scrollback_through_wait_resize_and_comp ); test.submit("START_ACTIVITY", "START_ACTIVITY"); test.wait_journal("reply-held"); - test.wait_screen("│ ◆ "); - test.wait_screen("│ Tip:"); - let initial_screen = test.screen(); + let initial_screen = test.wait_screen_with("│ ◆ ", &["│ Tip:", "START_ACTIVITY"]); let initial_rows: Vec<_> = initial_screen.lines().map(str::trim_end).collect(); let heading = initial_rows.iter().position(|line| *line == "Claude").expect("assistant heading"); @@ -708,22 +850,17 @@ fn activity_stays_above_queue_and_out_of_scrollback_through_wait_resize_and_comp ); assert!(heading < activity, "tip text mentioning Claude must not count as the heading"); assert!(!initial_screen.contains("Thinking…")); - let activity_header = test - .screen() - .lines() - .find(|line| line.starts_with("│ ◆ ")) - .expect("separate activity header") - .trim_end() - .to_owned(); - std::fs::write(&test.release_file, "thinking").expect("thinking boundary"); - test.wait_screen("◆ Thinking…"); - assert_eq!(test.screen().matches("Thinking…").count(), 1); - assert!(test.screen().contains(&activity_header), "thinking retains the activity verb"); + let activity_header = initial_rows[activity].to_owned(); + test.signal("thinking"); + // Thinking retains the activity verb. + let thinking = test.wait_screen_with("◆ Thinking…", &[&activity_header]); + assert_eq!(thinking.matches("Thinking…").count(), 1); test.submit("QUEUED_ACTIVITY", "QUEUED_ACTIVITY"); test.paste("DRAFT_ACTIVITY"); - test.wait_screen("DRAFT_ACTIVITY"); - test.wait_screen("│ Tip:"); - let screen = test.screen(); + let screen = test.wait_screen_with( + "DRAFT_ACTIVITY", + &["Thinking…", &activity_header, "│ Tip:", "QUEUED_ACTIVITY"], + ); assert!( screen.find("Thinking…").expect("assistant thinking") < screen.find(&activity_header).expect("activity") @@ -747,25 +884,29 @@ fn activity_stays_above_queue_and_out_of_scrollback_through_wait_resize_and_comp .nth(usize::from(row)) .is_some_and(|line| line.contains("DRAFT_ACTIVITY")) }); - std::fs::write(&test.release_file, "requires_action").expect("wait boundary"); + test.signal("requires_action"); test.wait_until("hidden activity while input is required", |test| { - !test.screen().contains("Thinking…") && !test.screen().contains("Tip:") + let screen = test.screen(); + !screen.contains("Thinking…") && !screen.contains("Tip:") }); - std::fs::write(&test.release_file, "running").expect("work resumes"); - test.wait_screen("Thinking…"); - test.wait_screen("Tip:"); - std::fs::write(&test.release_file, "working").expect("thinking ends"); - test.wait_until("ordinary activity after thinking", |test| { - !test.screen().contains("Thinking…") && test.screen().contains("Tip:") + test.signal("running"); + test.wait_screen_with("Thinking…", &["Tip:"]); + test.signal("working"); + test.wait_until("ordinary activity after thinking retains the activity verb", |test| { + let screen = test.screen(); + !screen.contains("Thinking…") + && screen.contains("Tip:") + && screen.contains(&activity_header) }); - assert!(test.screen().contains(&activity_header), "work retains the activity verb"); - test.resize(SHORT_ROWS, COLS); - std::fs::write(&test.release_file, "done").expect("completion"); - test.wait_screen("[READY]"); - test.wait_until("activity removed", |test| { - !test.screen().contains("Tip:") && !test.screen().contains("Thinking…") + test.resize_and_wait_for_draw(SHORT_ROWS, COLS); + test.signal("done"); + test.wait_until("activity removed and draft kept after completion", |test| { + let screen = test.screen(); + screen.contains("[READY]") + && screen.contains("DRAFT_ACTIVITY") + && !screen.contains("Tip:") + && !screen.contains("Thinking…") }); - test.wait_screen("DRAFT_ACTIVITY"); let output = test.output.lock().expect("output"); let mut screen = output.parser.screen().clone(); for offset in 0..100 { @@ -798,9 +939,7 @@ fn queued_prompt_starts_once_after_active_reply_and_preserves_the_next_draft() { ); test.release(); - test.wait_screen("reply 2 started"); - test.wait_screen("[READY]"); - test.wait_screen("UNSENT 🦀"); + test.wait_screen_with("reply 2 started", &["[READY]", "UNSENT 🦀"]); let journal = test.journal(); let queued = journal .iter() @@ -831,10 +970,9 @@ fn cancelling_a_stream_preserves_the_draft_and_allows_the_next_turn() { test.wait_screen("AFTER_CANCEL 界"); test.send(b"\x1b"); // Escape cancels a turn; Ctrl+C clears the composer. test.wait_journal("turn_interrupt_receipt"); - test.wait_screen("[READY]"); - test.resize(25, 61); - test.resize(38, 87); - test.wait_screen("AFTER_CANCEL 界"); + test.wait_screen_with("[READY]", &["AFTER_CANCEL 界"]); + test.resize_keeping(25, 61, &["AFTER_CANCEL 界"]); + test.resize_keeping(38, 87, &["AFTER_CANCEL 界"]); // Release the old barrier after cancel: a stray timer must not resume it. let before = test.journal().iter().filter(|e| e["event"] == "session_update").count(); test.release(); @@ -853,10 +991,9 @@ fn permission_accept_and_deny_survive_resize_and_send_one_correlated_response() for (keys, option) in [(b"\r".as_slice(), "allow-once"), (b"\x1b".as_slice(), "deny-once")] { let mut test = TerminalTest::start("permission", 3); test.submit("PERMISSION", "PERMISSION"); - test.wait_screen("Allow once"); - test.resize(25, 61); - test.resize(38, 87); - test.wait_screen("Deny once"); + test.wait_screen_with("Allow once", &["Deny once"]); + test.resize_keeping(25, 61, &["Allow once", "Deny once"]); + test.resize_keeping(38, 87, &["Allow once", "Deny once"]); assert!(test.commands("permission_response").is_empty()); test.send(keys); test.wait_screen("[READY]"); @@ -880,19 +1017,17 @@ fn question_selection_and_cancellation_survive_resize_without_submitting_a_promp for answered in [true, false] { let mut test = TerminalTest::start("question", 3); test.submit("QUESTION", "QUESTION"); - test.wait_screen("Choose fixture destination"); - test.resize(25, 61); - test.resize(38, 87); - test.wait_screen("Beta"); + test.wait_screen_with("Choose fixture destination", &["Beta"]); + test.resize_keeping(25, 61, &["Choose fixture destination", "Beta"]); + test.resize_keeping(38, 87, &["Choose fixture destination", "Beta"]); assert!(test.commands("question_response").is_empty()); if answered { test.send(b"\x1b[C"); // Select Beta. test.send(b"\t"); // Edit notes while the question owns focus. test.send(b"fixture note"); test.wait_screen("fixture note"); - test.resize(25, 61); - test.resize(38, 87); - test.wait_screen("fixture note"); + test.resize_keeping(25, 61, &["fixture note"]); + test.resize_keeping(38, 87, &["fixture note"]); test.send(b"\t"); test.send(b"\r"); } else { @@ -931,14 +1066,11 @@ fn bridge_eof_and_malformed_output_report_failure_and_exit_without_extra_submiss let status = test.child.wait_for_exit(Duration::from_secs(15)).expect("bounded fatal exit"); assert!(!status.success(), "protocol failure must produce a failing exit status"); test.assert_prompts(&["FAIL_BRIDGE"]); - let output = test.output.lock().expect("output lock"); - let raw = String::from_utf8_lossy(&output.raw); - let expected = if scenario == "hold-eof" { + let raw = test.wait_final_output(if scenario == "hold-eof" { "exited before completing the protocol" } else { "failed to decode bridge event json" - }; - assert!(raw.contains(expected), "missing user-facing error: {}", tail(&raw, 4000)); + }); assert!(!raw.contains(OWNED_REGION_ERROR)); if scenario == "hold-malformed" { assert_eq!( @@ -954,11 +1086,11 @@ fn bridge_eof_and_malformed_output_report_failure_and_exit_without_extra_submiss fn resizing_a_streamed_reply_preserves_rendering_and_clean_shutdown() { let mut test = TerminalTest::start("stream", 1500); test.submit("hello\n\nhow are you", "how are you"); - test.wait_screen("streamed line 5"); - // Terminal padding can leave spaces on visually blank rows. + // The stream scrolls the prompt off the screen, so it is read from the + // whole transcript. Terminal padding can leave spaces on visually blank rows. test.wait_until("submitted paragraph gap", |test| { - let screen = test.screen(); - let rows: Vec<_> = screen.lines().map(str::trim_end).collect(); + let transcript = test.transcript_rows(); + let rows: Vec<_> = transcript.iter().map(|row| row.trim_end()).collect(); rows.windows(3).any(|rows| rows == ["hello", "", "how are you"]) }); test.assert_prompts(&["hello\n\nhow are you"]); @@ -999,8 +1131,7 @@ fn width_and_height_resizes_preserve_unicode_multiline_draft_and_exact_submissio std::thread::sleep(Duration::from_millis(40)); } } - test.wait_screen("keep 界 🦀 e\u{301}"); - test.wait_screen("second paragraph"); + test.resize_keeping(30, 100, &["keep 界 🦀 e\u{301}", "second paragraph"]); test.assert_prompts(&["go"]); // Resizing must not submit the draft. test.release(); test.wait_journal("turn_complete"); @@ -1026,10 +1157,12 @@ fn fullscreen_resize_and_repeated_return_preserve_chat_and_next_submission() { test.wait_screen("Enter save"); test.send(b" draft"); test.wait_screen("German draft"); - test.resize(rows, cols); - test.wait_screen("German draft"); + test.resize_keeping(rows, cols, &["German draft"]); test.send(b"\x1b"); - test.wait_screen("Saved in User"); + test.wait_until("editor closed without saving", |test| { + let screen = test.screen(); + screen.contains("Saved in User") && !screen.contains("German draft") + }); test.send(b"\x1b"); test.wait_screen("streamed line 8"); assert!(!test.output.lock().expect("output lock").parser.screen().alternate_screen()); @@ -1061,9 +1194,9 @@ fn structured_settings_editor_adds_individual_rules_and_preserves_them_on_resize test.send(b"a"); test.paste("Bash(git push *)"); test.send(b"\r"); + test.wait_screen("Bash(git push *)"); for (rows, cols) in [(18, 61), (38, 87), (55, 120)] { - test.resize(rows, cols); - test.wait_screen("Bash(git push *)"); + test.resize_keeping(rows, cols, &["Bash(git push *)"]); } test.send(b"\x13"); test.wait_until("settings mutation", |test| test.commands("mutate_setting").len() == 1); @@ -1119,8 +1252,7 @@ fn guided_hook_creation_survives_resize_and_saves_a_complete_hook_with_existing_ test.wait_screen("Review"); test.send(b"\r\x1b[B"); for (rows, cols) in [(16, 42), (38, 87), (55, 120)] { - test.resize(rows, cols); - test.wait_screen("npm run lint"); + test.resize_keeping(rows, cols, &["npm run lint"]); } test.send(b"\x13"); test.wait_until("hook settings mutation", |test| test.commands("mutate_setting").len() == 1); @@ -1204,11 +1336,10 @@ fn a_stream_error_preserves_the_next_unsent_draft_through_resize() { test.wait_screen("keep this paragraph"); test.release(); test.wait_journal("turn_error"); - test.wait_screen("Fixture service unavailable"); - test.resize(55, 120); - test.resize(38, 87); - test.wait_screen("NEXT_DRAFT 界 🦀"); - test.wait_screen("keep this paragraph"); + let draft = ["NEXT_DRAFT 界 🦀", "keep this paragraph"]; + test.wait_screen_with("Fixture service unavailable", &draft); + test.resize_keeping(55, 120, &draft); + test.resize_keeping(38, 87, &draft); test.assert_prompts(&["go"]); test.shutdown(); } @@ -1221,11 +1352,10 @@ fn presentation_clocks_and_copy_picker_navigation_survive_terminal_resize() { test.wait_screen("Done "); test.wait_screen("15:02Z"); test.submit("/copy", "/copy"); - test.wait_screen("Copy last response"); - test.wait_screen("Code block 1 (rust)"); + // The command menu already shows the picker's title as its description. + test.wait_screen_with("Code block 1 (rust)", &["Copy last response"]); test.send(b"\x1b[B"); - test.resize(24, 42); - test.wait_screen("Code block 1 (rust)"); + test.resize_keeping(24, 42, &["Code block 1 (rust)"]); test.send(b"\x1b"); test.wait_screen("Type a message"); test.submit("Follow up", "Follow up"); @@ -1262,18 +1392,11 @@ fn completed_transcript_remains_scrollable_after_zoom_in_and_out() { false, ); test.submit("SCROLLBACK_CONTROL", "SCROLLBACK_CONTROL"); - test.wait_journal("turn_complete"); - test.wait_screen("[READY]"); - test.wait_until("completed transcript in terminal history", |test| { - test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 1")) - }); - test.assert_completed_reply_in_scrollback(80); + test.wait_turn_finished("streamed line 80"); + test.wait_completed_reply_in_scrollback(80); for (rows, cols) in [(30, 64), (2, 64), (1, 64), (55, 120), (38, 87)] { test.resize_and_wait_for_draw(rows, cols); - test.wait_until("replayed transcript has reached its final row", |test| { - test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 80")) - }); - test.assert_completed_reply_in_scrollback(80); + test.wait_completed_reply_in_scrollback(80); } test.shutdown(); } @@ -1288,12 +1411,8 @@ fn a_new_resize_supersedes_active_replay_and_restores_history_at_the_latest_size false, ); test.submit("LARGE_REPLAY", "LARGE_REPLAY"); - test.wait_journal("turn_complete"); - test.wait_screen("[READY]"); - test.wait_until("large completed transcript is in scrollback", |test| { - test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 1")) - }); - test.assert_completed_reply_in_scrollback(2_000); + test.wait_turn_finished("streamed line 2000"); + test.wait_completed_reply_in_scrollback(2_000); let offset = std::fs::read(test.temp.path().join("runtime.log")).expect("runtime log").len(); test.resize(12, 64); test.wait_until("replay has started inserting history", |test| { @@ -1303,10 +1422,7 @@ fn a_new_resize_supersedes_active_replay_and_restores_history_at_the_latest_size }) }); test.resize_and_wait_for_draw(55, 120); - test.wait_until("latest replay has reached its final row", |test| { - test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 2000")) - }); - test.assert_completed_reply_in_scrollback(2_000); + test.wait_completed_reply_in_scrollback(2_000); let replay_frames: Vec<_> = test .runtime_records_since(offset) .into_iter() @@ -1346,22 +1462,17 @@ fn completed_transcript_remains_scrollable_when_zooming_during_an_unfinished_cod false, ); test.submit("COMPLETED_PREFIX", "COMPLETED_PREFIX"); - test.wait_journal("turn_complete"); - test.wait_screen("[READY]"); - test.assert_completed_reply_in_scrollback(80); + test.wait_turn_finished("streamed line 80"); + test.wait_completed_reply_in_scrollback(80); test.submit("UNFINISHED_CODE_BLOCK", "UNFINISHED_CODE_BLOCK"); test.wait_journal("reply-held"); test.wait_screen("code line 80"); - test.resize_and_wait_for_draw(30, 64); - test.wait_screen("code line 80"); + test.resize_keeping(30, 64, &["code line 80"]); let prefix_survived_zoom_in = test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 1")); let zoom_in_diagnostics = test.diagnostics(); test.resize_and_wait_for_draw(120, 120); - test.wait_until("completed prefix returns when the terminal grows", |test| { - test.transcript_rows().iter().any(|row| row.trim_end().ends_with("streamed line 1")) - }); - test.assert_completed_reply_in_scrollback(80); + test.wait_completed_reply_in_scrollback(80); test.release(); test.wait_screen("[READY]"); test.shutdown(); @@ -1381,16 +1492,14 @@ fn completed_transcript_remains_scrollable_when_resizing_in_reading_mode() { false, ); test.submit("READING_PREFIX", "READING_PREFIX"); - test.wait_journal("turn_complete"); - test.wait_screen("[READY]"); - test.assert_completed_reply_in_scrollback(80); + test.wait_turn_finished("streamed line 80"); + test.wait_completed_reply_in_scrollback(80); test.send(b"\x1b[5~"); test.wait_screen("Reading output"); // Establish actual terminal history before the resize clears it. - test.assert_completed_reply_in_scrollback(80); - test.resize_and_wait_for_draw(30, 64); - test.wait_screen("Reading output"); - test.assert_completed_reply_in_scrollback(80); + test.wait_completed_reply_in_scrollback(80); + test.resize_keeping(30, 64, &["Reading output"]); + test.wait_completed_reply_in_scrollback(80); test.shutdown(); } @@ -1399,25 +1508,19 @@ fn reading_output_stays_anchored_through_streaming_resize_and_explicit_return_li let mut test = TerminalTest::start("hold-presentation", 80); test.submit("Reading test", "Reading test"); test.wait_journal("reading-barrier"); + // The barrier is the bridge's view. Page Up must reach an app that has + // already rendered the held output. + test.wait_screen("streamed line 20"); test.send(b"\x1b[5~"); // Page Up enters reading mode. - test.wait_screen("Reading output"); - let pinned = test - .screen() - .lines() - .find(|line| line.contains("streamed line")) - .expect("visible output anchor") - .trim() - .to_owned(); + let pinned = test.wait_pinned_output(); test.release(); - test.wait_journal("turn_complete"); - test.wait_screen(&pinned); + let completed = test.wait_screen_with("[READY]", &[&pinned]); assert!( - !test.screen().contains("streamed line 80"), + !completed.contains("streamed line 80"), "paused output jumped to the tail: {}", test.diagnostics() ); - test.resize(TALL_ROWS, 64); - test.wait_screen(&pinned); + test.resize_keeping(TALL_ROWS, 64, &[&pinned]); test.send(b"\x1b[1;5F"); // Ctrl+End explicitly returns to live output. test.wait_screen("streamed line 80"); test.wait_until("following restored", |test| !test.screen().contains("Reading output")); @@ -1434,20 +1537,12 @@ fn saved_auto_scroll_off_holds_new_output_until_the_user_returns_live() { test.send(b"\x1b[C"); // On -> Off at the selected user scope. test.wait_setting("profile/settings.json", "/autoScrollEnabled", Some(&Value::Bool(false))); test.send(b"\x1b"); - test.wait_screen("Reading output"); - let pinned = test - .screen() - .lines() - .find(|line| line.contains("streamed line")) - .expect("visible anchor") - .trim() - .to_owned(); + let pinned = test.wait_pinned_output(); test.release(); - test.wait_journal("turn_complete"); - test.wait_screen(&pinned); + test.wait_screen_with("[READY]", &[&pinned]); test.send(b"\x1b[1;5F"); - test.wait_screen("streamed line 80"); - test.wait_screen("Reading output"); // The saved Off preference still holds after the jump. + // The saved Off preference still holds after the jump. + test.wait_screen_with("streamed line 80", &["Reading output"]); let saved: Value = serde_json::from_slice( &std::fs::read(test.temp.path().join("profile/settings.json")).expect("saved preferences"), ) @@ -1494,10 +1589,8 @@ fn notifications_follow_focus_saved_categories_and_sdk_delivery_provenance_in_a_ test.wait_screen("Native notice 1"); // A visible notice does not imply a desktop alert. test.send(b"\x1b[I"); // Focus gained. test.submit("Focused test", "Focused test"); - test.wait_screen("reply 2 started"); - test.wait_until("focused turn finished", |test| { - test.journal().iter().filter(|entry| entry["event"] == "turn_complete").count() == 2 - }); + // The bell count is only meaningful once the app has applied the turn. + test.wait_screen_with("Native notice 2", &["[READY]"]); assert_eq!(notification_bells(&test), 1); test.submit("/config", "/config"); test.wait_settings_ready("Notification method"); @@ -1512,10 +1605,7 @@ fn notifications_follow_focus_saved_categories_and_sdk_delivery_provenance_in_a_ test.wait_screen("Type a message"); test.send(b"\x1b[O"); test.submit("Disabled category", "Disabled category"); - test.wait_until("third turn finished", |test| { - test.journal().iter().filter(|entry| entry["event"] == "turn_complete").count() == 3 - }); - test.wait_screen("Native notice 3"); + test.wait_screen_with("Native notice 3", &["[READY]"]); assert_eq!(notification_bells(&test), 1); let saved: Value = serde_json::from_slice( &std::fs::read(test.temp.path().join("profile/app-settings.json")) From 1884ccc872a9c68556ed1af57674844c908b38e5 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 13:54:27 +0200 Subject: [PATCH 3/8] fix(agent-sdk): restore resume history across compactions - reconnect transcript history through logical compaction links - preserve string messages and hide internal task notifications - retain SDK branch filtering without modifying saved transcripts - add SDK history and bridge resume regression coverage --- agent-sdk/src/bridge.contract.test.ts | 88 ++++++++++++++++- agent-sdk/src/bridge/command_lifecycle.ts | 9 +- agent-sdk/src/bridge/history.test.ts | 113 ++++++++++++++++++++++ agent-sdk/src/bridge/history.ts | 47 ++++++++- 4 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 agent-sdk/src/bridge/history.test.ts diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index 6c689185..ca92353e 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -1,12 +1,13 @@ import test from "node:test"; import assert from "node:assert/strict"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import readline from "node:readline"; import { fileURLToPath, pathToFileURL } from "node:url"; import type { SettingsMutation, SettingsResult, SettingsSnapshot } from "./types.js"; +import type { SessionUpdate } from "./types.js"; type BridgeEnvelope = Record; @@ -378,6 +379,7 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB export * from ${JSON.stringify(sdkPath)}; import { appendFileSync } from "node:fs"; import { resolveSettings as sdkResolveSettings } from ${JSON.stringify(sdkPath)}; + import { getSessionMessages as sdkGetSessionMessages, importSessionToStore as sdkImportSessionToStore } from ${JSON.stringify(sdkPath)}; export async function resolveSettings(options) { const managedSettings = process.env.SETTINGS_POLICY_FIXTURE; return sdkResolveSettings({ ...options, ...(managedSettings ? { serverManagedSettings: JSON.parse(managedSettings) } : {}) }); @@ -388,7 +390,8 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB const seeded = JSON.parse(process.env.STARTUP_SESSIONS_FIXTURE ?? "[]"); return [...seeded, ...[...saved.keys()].map((sessionId, index) => ({ sessionId, cwd: saved.get(sessionId).cwd, lastModified: index + 1 }))].filter(entry => !options?.dir || entry.cwd === options.dir); } - export async function getSessionMessages() { return []; } + export async function getSessionMessages(...args) { return process.env.RESUME_TRANSCRIPT_FIXTURE === "1" ? sdkGetSessionMessages(...args) : []; } + export async function importSessionToStore(...args) { if (process.env.RESUME_TRANSCRIPT_FIXTURE === "1") await sdkImportSessionToStore(...args); } const saved = new Map(); export function query({ prompt, options }) { record({ type: "query", cwd: options.cwd, resume: options.resume, model: options.model, effort: options.effort, permissionMode: options.permissionMode, agent: options.agent }); @@ -506,6 +509,87 @@ async function nextUltracode(bridge: SpawnedBridge): Promise { return (event.update as BridgeEnvelope).ultracode; } +test("resume command restores the screenshot conversation without internal task XML", async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "claude-rs-resume-"))); + const cwd = process.cwd(); + const configDir = join(directory, "config"); + const projectDir = join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); + mkdirSync(projectDir, { recursive: true }); + const sessionId = "f96f66ae-b5d4-4bc2-b5a3-08336cf4e850"; + const completed = ` +bbwikd6z7 +toolu_01DExEGoUHANdvdafPRrG1ks +/fixture/tasks/bbwikd6z7.output +completed + +Background command "Check docs references to render events and read the fake auth CLI" completed (exit code 0) +`; + const stopped = ` +bdxdmq200 +toolu_01CeX92NEHVLshRmsFsVRMX5 +stopped +Background shell command didn't finish before the previous session ended +No completion record was found for it in the previous session. It may have been stopped (via the UI, Monitor timeout, or agent teardown — these leave no transcript marker), or it may have been running when the previous Claude Code process exited. Check the output file for partial results before assuming it completed. +`; + const records: Record[] = []; + const add = (uuid: string, parentUuid: string | null, type: string, message: unknown, extra = {}) => { + records.push({ uuid, parentUuid, type, message, sessionId, cwd, timestamp: `2026-10-06T11:07:${String(records.length).padStart(2, "0")}.000Z`, ...extra }); + }; + add("earlier-user", null, "user", { role: "user", content: [{ type: "text", text: "Earlier conversation" }] }); + add("earlier-answer", "earlier-user", "assistant", { id: "earlier", role: "assistant", content: [{ type: "text", text: "Earlier response" }] }); + add("boundary", null, "system", undefined, { subtype: "compact_boundary", logicalParentUuid: "earlier-answer" }); + add("compact-summary", "boundary", "user", { role: "user", content: "Internal compaction summary" }, { isCompactSummary: true }); + add("check", "compact-summary", "user", { role: "user", content: "any other places/test that had this place useless sloppy issue?" }, { origin: { kind: "human" } }); + add("tool", "check", "assistant", { id: "read", role: "assistant", content: [{ type: "tool_use", id: "read-source", name: "Read", input: { file_path: "fixture.xml" } }] }); + add("tool-result", "tool", "user", { role: "user", content: [{ type: "tool_result", tool_use_id: "read-source", content: "legitimate XML tool output" }] }); + add("completed", "tool-result", "user", { role: "user", content: completed }, { origin: { kind: "task-notification", producer: "session-task" } }); + add("interrupted", "completed", "user", { role: "user", content: [{ type: "text", text: "[Request interrupted by user]" }] }); + add("no-response", "interrupted", "assistant", { id: "interrupted-response", role: "assistant", content: [{ type: "text", text: "No response requested." }] }); + add("stopped", "no-response", "user", { role: "user", content: stopped }, { origin: { kind: "task-notification" } }); + add("hello", "stopped", "user", { role: "user", content: [{ type: "text", text: "hello? does this session onyl hold two messages?" }] }, { origin: { kind: "human" } }); + add("answer", "hello", "assistant", { id: "answer", role: "assistant", content: [{ type: "text", text: "No, the session holds everything." }] }); + add("user-xml", "answer", "user", { role: "user", content: [{ type: "text", text: "user-provided XML" }] }, { origin: { kind: "human" } }); + const transcriptPath = join(projectDir, `${sessionId}.jsonl`); + const transcript = `${records.map(record => JSON.stringify(record)).join("\n")}\n`; + writeFileSync(transcriptPath, transcript); + const { bridge, cleanup } = ultracodeFixtureBridge({ + CLAUDE_CONFIG_DIR: configDir, + CLAUDE_CODE_PROJECT_DIR_NAME: undefined, + RESUME_TRANSCRIPT_FIXTURE: "1", + STARTUP_SESSIONS_FIXTURE: JSON.stringify([{ sessionId, cwd, lastModified: 1 }]), + }); + try { + bridge.writeCommand({ command: "resume_session", session_id: sessionId, request_id: "resume-screenshot", launch_settings: {} }); + const connected = await nextMatching(bridge, event => event.event === "connected" || event.event === "slash_command_error"); + assertProtocolEvent(connected, "connected"); + assert.equal(connected.session_id, sessionId); + assert.equal(connected.request_id, "resume-screenshot"); + const updates = connected.history_updates as SessionUpdate[]; + const text = updates.filter(update => update.type === "user_message_chunk" || update.type === "agent_message_chunk"); + assert.deepEqual(text.map(update => [update.type, update.source_message_uuid, update.content]), [ + ["user_message_chunk", "earlier-user", { type: "text", text: "Earlier conversation" }], + ["agent_message_chunk", "earlier-answer", { type: "text", text: "Earlier response" }], + ["user_message_chunk", "check", { type: "text", text: "any other places/test that had this place useless sloppy issue?" }], + ["user_message_chunk", "interrupted", { type: "text", text: "[Request interrupted by user]" }], + ["agent_message_chunk", "no-response", { type: "text", text: "No response requested." }], + ["user_message_chunk", "hello", { type: "text", text: "hello? does this session onyl hold two messages?" }], + ["agent_message_chunk", "answer", { type: "text", text: "No, the session holds everything." }], + ["user_message_chunk", "user-xml", { type: "text", text: "user-provided XML" }], + ]); + assert.deepEqual(updates.filter(update => update.type === "message_metadata").map(update => update.source_message_uuid), ["earlier-user", "earlier-answer", "check", "tool", "tool-result", "interrupted", "no-response", "hello", "answer", "user-xml"]); + const tools = updates.filter(update => update.type === "tool_call"); + assert.equal(tools.length, 1); + assert.equal(tools[0].tool_call.tool_call_id, "read-source"); + assert.equal(tools[0].tool_call.status, "completed"); + assert.equal(tools[0].tool_call.raw_output, "legitimate XML tool output"); + assert.equal(readFileSync(transcriptPath, "utf8"), transcript); + } finally { + await cleanup(); + assert.equal(dirname(directory), realpathSync(tmpdir())); + rmSync(directory, { recursive: true, force: true }); + } +}); + test("spawned bridge restores normalized command inventory after repeated clear and a new session", async () => { const { bridge, cleanup } = ultracodeFixtureBridge({ SLASH_COMMANDS_FIXTURE: "1" }); const commandUpdate = (event: BridgeEnvelope) => (event.update as BridgeEnvelope)?.type === "available_commands_update"; diff --git a/agent-sdk/src/bridge/command_lifecycle.ts b/agent-sdk/src/bridge/command_lifecycle.ts index d987f643..76ff9513 100644 --- a/agent-sdk/src/bridge/command_lifecycle.ts +++ b/agent-sdk/src/bridge/command_lifecycle.ts @@ -12,7 +12,7 @@ import { slashError, writeEvent, } from "./events.js"; -import { mapSessionMessagesToUpdates } from "./history.js"; +import { getSessionTranscriptMessages, mapSessionMessagesToUpdates } from "./history.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; import { awaitSessionInitialization, @@ -286,11 +286,11 @@ async function resume( return; } setSessionListingDir(matched.cwd ?? process.cwd()); - const historyMessages = await getSessionMessages( + const historyMessages = await getSessionTranscriptMessages( command.session_id, matched.cwd - ? { dir: matched.cwd, includeSystemMessages: true } - : { includeSystemMessages: true }, + ? { dir: matched.cwd } + : {}, ); const resumeUpdates = mapSessionMessagesToUpdates(historyMessages); const staleSessions = Array.from(sessions.values()); @@ -303,6 +303,7 @@ async function resume( ...(requestId ? { requestId } : {}), sessionId: command.session_id, fields: { + history_message_count: historyMessages.length, history_update_count: resumeUpdates.length, stale_session_count: staleSessions.length, }, diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts new file mode 100644 index 00000000..446d607b --- /dev/null +++ b/agent-sdk/src/bridge/history.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { mapSessionMessagesToUpdates } from "./history.js"; + +test("resume display restores compaction segments once and preserves SDK branch filtering", () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "claude-rs-transcript-"))); + try { + const cwd = join(directory, "project"); + const configDir = join(directory, "config"); + const projectDir = join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); + mkdirSync(cwd, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + const sessionId = "11111111-1111-4111-8111-111111111111"; + const records: Record[] = []; + const add = (uuid: string, parentUuid: string | null, type: string, message: unknown, extra = {}) => { + records.push({ uuid, parentUuid, type, message, sessionId, cwd, timestamp: `2026-10-06T10:00:${String(records.length).padStart(2, "0")}.000Z`, ...extra }); + }; + add("prompt-1", null, "user", { role: "user", content: "First prompt" }); + add("tool", "prompt-1", "assistant", { id: "assistant-1", role: "assistant", content: [{ type: "tool_use", id: "read-1", name: "Read", input: { file_path: "fixture.rs" } }] }); + add("result", "tool", "user", { role: "user", content: [{ type: "tool_result", tool_use_id: "read-1", content: "file contents" }] }); + add("answer-1", "result", "assistant", { id: "assistant-2", role: "assistant", content: [{ type: "text", text: "First answer" }] }); + add("boundary-1", null, "system", undefined, { subtype: "compact_boundary", logicalParentUuid: "answer-1", compactMetadata: { trigger: "auto", preservedMessages: { anchorUuid: "summary-1", uuids: ["tool", "result", "answer-1"] } } }); + add("summary-1", "boundary-1", "user", { role: "user", content: "Internal summary one" }, { isCompactSummary: true }); + add("prompt-2", "summary-1", "user", { role: "user", content: [{ type: "text", text: "Second prompt" }] }); + add("discarded", "prompt-2", "assistant", { id: "discarded-branch", role: "assistant", content: [{ type: "text", text: "Discarded answer" }] }); + add("answer-2", "prompt-2", "assistant", { id: "assistant-3", role: "assistant", content: [{ type: "text", text: "Second answer" }] }); + add("boundary-2", null, "system", undefined, { subtype: "compact_boundary", logicalParentUuid: "answer-2", compactMetadata: { trigger: "auto", preservedSegment: { headUuid: "prompt-2", anchorUuid: "summary-2", tailUuid: "answer-2" } } }); + add("summary-2", "boundary-2", "user", { role: "user", content: [{ type: "text", text: "Internal summary two" }] }, { isCompactSummary: true }); + add("prompt-3", "summary-2", "user", { role: "user", content: "Third prompt" }); + add("answer-3", "prompt-3", "assistant", { id: "assistant-4", role: "assistant", content: [{ type: "text", text: "Third answer" }] }); + add("sidechain", "answer-3", "assistant", { id: "sidechain", role: "assistant", content: [{ type: "text", text: "Subagent output" }] }, { isSidechain: true }); + // Exercise the SDK's five-megabyte pre-compaction read optimization too. + records.splice(4, 0, { type: "file-history-snapshot", padding: "x".repeat(6 * 1024 * 1024) }); + const transcript = join(projectDir, `${sessionId}.jsonl`); + const before = `${records.map(record => JSON.stringify(record)).join("\n")}\n`; + writeFileSync(transcript, before); + const script = ` + import { getSessionTranscriptMessages, mapSessionMessagesToUpdates } from ${JSON.stringify(new URL("./history.js", import.meta.url).href)}; + const messages = await getSessionTranscriptMessages(${JSON.stringify(sessionId)}); + const updates = mapSessionMessagesToUpdates(messages); + console.log(JSON.stringify({ + uuids: messages.map(message => message.uuid), updates + })); + `; + const child = spawnSync(process.execPath, ["--input-type=module", "-"], { + input: script, + encoding: "utf8", + env: { ...process.env, CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: undefined }, + windowsHide: true, + }); + assert.equal(child.status, 0, child.stderr); + const result = JSON.parse(child.stdout); + assert.deepEqual(result.uuids, ["prompt-1", "tool", "result", "answer-1", "boundary-1", "summary-1", "prompt-2", "answer-2", "boundary-2", "summary-2", "prompt-3", "answer-3"]); + const chunks = result.updates.filter((update: { type: string }) => update.type === "user_message_chunk" || update.type === "agent_message_chunk"); + assert.deepEqual(chunks.map((update: { content: { text: string } }) => update.content.text), ["First prompt", "First answer", "Second prompt", "Second answer", "Third prompt", "Third answer"]); + const tools = result.updates.filter((update: { type: string }) => update.type === "tool_call"); + assert.equal(tools.length, 1); + assert.equal(tools[0].tool_call.tool_call_id, "read-1"); + assert.equal(tools[0].tool_call.status, "completed"); + assert.equal(tools[0].tool_call.raw_output, "file contents"); + assert.equal(readFileSync(transcript, "utf8"), before); + } finally { + assert.equal(dirname(directory), realpathSync(tmpdir())); + rmSync(directory, { recursive: true, force: true }); + } +}); + +test("resume display excludes internal task notifications while preserving user XML and external messages", () => { + const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null, timestamp: "2026-10-06T11:10:22.531Z" }; + const completed = "\ntask-1\ncompleted\nBackground command completed\n"; + const stopped = "\ntask-2\nstopped\nBackground shell command stopped\n"; + const messages = [ + { ...base, type: "user" as const, uuid: "before", message: { role: "user", content: "Before notification" } }, + { ...base, type: "user" as const, uuid: "completed", origin: { kind: "task-notification", producer: "session-task" }, message: { role: "user", content: completed } }, + { ...base, type: "user" as const, uuid: "stopped", origin: { kind: "task-notification" }, message: { role: "user", content: [{ type: "text", text: stopped }] } }, + { ...base, type: "user" as const, uuid: "after", origin: { kind: "human" }, message: { role: "user", content: "hello?" } }, + { ...base, type: "user" as const, uuid: "user-xml", origin: { kind: "human" }, message: { role: "user", content: completed } }, + { ...base, type: "assistant" as const, uuid: "assistant-xml", message: { role: "assistant", content: "XML output" } }, + { ...base, type: "user" as const, uuid: "peer", origin: { kind: "task-notification", subkind: "peer-send-message" }, message: { role: "user", content: "Peer message" } }, + { ...base, type: "user" as const, uuid: "scheduled", origin: { kind: "task-notification", subkind: "scheduled-trigger" }, message: { role: "user", content: "Scheduled message" } }, + ]; + const before = structuredClone(messages); + const updates = mapSessionMessagesToUpdates(messages); + const chunks = updates.filter(update => update.type === "user_message_chunk" || update.type === "agent_message_chunk"); + assert.deepEqual(chunks.map(update => [update.source_message_uuid, update.content]), [ + ["before", { type: "text", text: "Before notification" }], + ["after", { type: "text", text: "hello?" }], + ["user-xml", { type: "text", text: completed }], + ["assistant-xml", { type: "text", text: "XML output" }], + ["peer", { type: "text", text: "Peer message" }], + ["scheduled", { type: "text", text: "Scheduled message" }], + ]); + assert.deepEqual(updates.filter(update => update.type === "message_metadata").map(update => update.source_message_uuid), ["before", "after", "user-xml", "assistant-xml", "peer", "scheduled"]); + assert.deepEqual(messages, before); +}); + +test("resume display maps plain string messages and hides internal compact summaries", () => { + const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; + const updates = mapSessionMessagesToUpdates([ + { ...base, type: "user", uuid: "user", message: { role: "user", content: "User prompt" } }, + { ...base, type: "assistant", uuid: "assistant", message: { role: "assistant", content: "Assistant response" } }, + { ...base, type: "user", uuid: "summary", message: { role: "user", content: [{ type: "text", text: "Internal summary" }] }, ...{ isCompactSummary: true } }, + ]); + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk" || update.type === "agent_message_chunk").map(update => update.content), [ + { type: "text", text: "User prompt" }, + { type: "text", text: "Assistant response" }, + ]); + assert.ok(!updates.some(update => update.type === "message_metadata" && update.source_message_uuid === "summary")); +}); diff --git a/agent-sdk/src/bridge/history.ts b/agent-sdk/src/bridge/history.ts index d71481a4..bdf9a024 100644 --- a/agent-sdk/src/bridge/history.ts +++ b/agent-sdk/src/bridge/history.ts @@ -1,8 +1,11 @@ import { messageMetadata } from "./presentation_metadata.js"; import { nativeNotification } from "./notifications.js"; +import { getSessionMessages, importSessionToStore } from "@anthropic-ai/claude-agent-sdk"; import type { SDKSessionInfo, SessionMessage, + SessionStore, + SessionStoreEntry, } from "@anthropic-ai/claude-agent-sdk"; import type { Json, @@ -24,6 +27,37 @@ import { parseToolNonExecutionMetadata, } from "./tooling.js"; +/** Load display history across compactions without changing the model's saved context. */ +export async function getSessionTranscriptMessages( + sessionId: string, + options: { dir?: string } = {}, +): Promise { + const entries: SessionStoreEntry[] = []; + const store: SessionStore = { + append: async (_key, batch) => { entries.push(...batch); }, + load: async () => { + const uuids = new Set(entries.map(entry => entry.uuid)); + return entries.map(entry => { + if ( + entry.type !== "system" || entry.subtype !== "compact_boundary" || + typeof entry.logicalParentUuid !== "string" || !uuids.has(entry.logicalParentUuid) + ) { + return entry; + } + // Preserved messages already occur in the original transcript chain. + // Reparenting them after the summary would create a cycle across this link. + const compactMetadata = { ...asRecordOrNull(entry.compactMetadata) }; + delete compactMetadata.preservedMessages; + delete compactMetadata.preservedSegment; + return { ...entry, parentUuid: entry.logicalParentUuid, compactMetadata }; + }); + }, + }; + // Import streams the complete file, including history before the SDK's large-file cutoff. + await importSessionToStore(sessionId, store, { ...options, includeSubagents: false }); + return getSessionMessages(sessionId, { ...options, includeSystemMessages: true, sessionStore: store }); +} + function nonEmptyTrimmed(value: unknown): string | undefined { if (typeof value !== "string") { return undefined; @@ -397,6 +431,15 @@ export function mapSessionMessagesToUpdates( const taskToolUseIds = new Map(); for (const entry of messages) { + const record = asRecordOrNull(entry); + const origin = asRecordOrNull(record?.origin); + // Background-task inputs belong to Claude's context, not the visible user transcript. + // Peer and scheduled messages carry a subkind and remain eligible for display. + if (record?.isCompactSummary === true || ( + entry.type === "user" && origin?.kind === "task-notification" && + origin.subkind === undefined && + (origin.producer === undefined || origin.producer === "session-task") + )) continue; const fallbackRole = entry.type === "assistant" ? "assistant" : "user"; const entrySourceMessageUuid = typeof entry.uuid === "string" ? entry.uuid : undefined; @@ -438,7 +481,9 @@ export function mapSessionMessagesToUpdates( ? message.parent_tool_use_id : null; - const content = Array.isArray(message.content) ? message.content : []; + const content = typeof message.content === "string" + ? [{ type: "text", text: message.content }] + : Array.isArray(message.content) ? message.content : []; const nonExecutionByToolUseId = parseToolNonExecutionMetadata( Object.hasOwn(entry, "tool_result_meta") ? (entry as unknown as Record).tool_result_meta From b9d4563dcbe0b63e4d023c004ec6771b8fc64b0a Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 15:57:28 +0200 Subject: [PATCH 4/8] fix(tui): preserve background tool rendering and conversation flow - detach confirmed background tools and flush launch cards in creation order - append one linked result when each background task finishes - preserve task correlation and interactive controls across turns and resume - keep completion replies streaming correctly and group consecutive Claude output - cover lifecycle, cancellation, resize, and normal conversation with functional tests --- agent-sdk/src/bridge.contract.test.ts | 233 +++++++++++++++++- agent-sdk/src/bridge.test.ts | 41 +++- agent-sdk/src/bridge/activity.ts | 1 + agent-sdk/src/bridge/history.test.ts | 6 +- agent-sdk/src/bridge/history.ts | 272 ++++++++-------------- agent-sdk/src/bridge/message_handlers.ts | 207 ++++++++-------- agent-sdk/src/bridge/session_lifecycle.ts | 2 + agent-sdk/src/bridge/tasks.ts | 24 +- agent-sdk/src/bridge/tool_calls.ts | 78 ++++--- agent-sdk/src/bridge/tooling.test.ts | 4 +- agent-sdk/src/bridge/tooling.ts | 11 +- agent-sdk/src/types.ts | 1 + docs/src/architecture.md | 8 + src/agent/model/session.rs | 1 + src/agent/types.rs | 1 + src/agent/wire.rs | 14 ++ src/app/activity.rs | 13 +- src/app/connect/mod.rs | 1 + src/app/connect/type_converters.rs | 3 + src/app/events/mod.rs | 6 +- src/app/events/notifications.rs | 4 +- src/app/events/retraction.rs | 2 +- src/app/events/session_reset.rs | 4 +- src/app/events/streaming.rs | 12 + src/app/events/tests/client_events.rs | 4 +- src/app/events/tests/mod.rs | 157 ++++++++++--- src/app/events/tool_calls.rs | 15 +- src/app/events/tool_updates.rs | 35 +-- src/app/events/turn.rs | 65 +++++- src/app/inline_interactions.rs | 33 ++- src/app/keys.rs | 2 +- src/app/mod.rs | 4 +- src/app/permissions.rs | 13 +- src/app/questions.rs | 9 +- src/app/state/app.rs | 6 +- src/app/state/focus_runtime.rs | 4 +- src/app/state/history_retention.rs | 22 +- src/app/state/messages.rs | 6 + src/app/state/render_budget.rs | 26 +-- src/app/state/tests.rs | 25 +- src/app/state/tool_call_info.rs | 80 +++++++ src/app/state/tool_tracking.rs | 110 ++++++++- src/app/state/turn.rs | 5 - src/app/tasks.rs | 1 + src/app/terminal_runtime/chat_session.rs | 2 +- src/app/test_support.rs | 1 + src/app/user_dialog.rs | 10 +- src/app/view/tests.rs | 2 +- src/ui/footer_rows.rs | 6 +- src/ui/help.rs | 6 +- src/ui/inline_chat_rows.rs | 224 ++++++++++++++---- src/ui/input.rs | 2 +- src/ui/input_rows.rs | 2 +- src/ui/message_rows.rs | 1 + src/ui/theme.rs | 1 + src/ui/tool_call/interactions.rs | 1 + src/ui/tool_call/mod.rs | 4 +- src/ui/tool_call/tests.rs | 6 + tests/fixtures/fake-bridge.js | 104 ++++++++- tests/integration/permissions.rs | 16 +- tests/terminal_resize.rs | 269 +++++++++++++++++++++ 61 files changed, 1656 insertions(+), 572 deletions(-) diff --git a/agent-sdk/src/bridge.contract.test.ts b/agent-sdk/src/bridge.contract.test.ts index ca92353e..74ebfa4b 100644 --- a/agent-sdk/src/bridge.contract.test.ts +++ b/agent-sdk/src/bridge.contract.test.ts @@ -162,6 +162,228 @@ function assertProtocolEvent( assert.equal(envelope.event, eventName); } +test("bridge process keeps initial and later background tasks linked across turns and settles actual outcomes", async () => { + for (const toolName of ["Bash", "Agent"]) { + for (const initiallyBackgrounded of [true, false]) { + for (const outcome of ["completed", "failed", "stopped"]) { + const toolId = "primary-tool"; + const taskId = "primary-task"; + const system = (subtype: string, fields: BridgeEnvelope) => ({ type: "system", subtype, uuid: crypto.randomUUID(), task_id: taskId, ...fields }); + const toolResult = (raw: BridgeEnvelope) => ({ type: "user", uuid: crypto.randomUUID(), tool_use_result: raw, message: { role: "user", content: [{ type: "tool_result", tool_use_id: toolId, content: "Launch acknowledgement" }] } }); + const turnEnd = { type: "result", subtype: "success", is_error: false, duration_ms: 1, num_turns: 1, result: "Foreground finished", usage: {}, modelUsage: {}, permission_denials: [] }; + const fixture = { + start: [ + { type: "assistant", message: { id: "assistant-1", role: "assistant", content: [{ type: "tool_use", id: toolId, name: toolName, input: toolName === "Bash" ? { command: "long command", run_in_background: initiallyBackgrounded } : { description: "Investigate", prompt: "Investigate", run_in_background: initiallyBackgrounded } }] } }, + system("task_started", { tool_use_id: toolId, description: "Investigate", task_type: toolName === "Bash" ? "local_bash" : "local_agent", is_backgrounded: initiallyBackgrounded }), + ...(!initiallyBackgrounded ? [system("task_updated", { patch: { status: "running", is_backgrounded: true } })] : []), + toolResult(toolName === "Bash" ? { stdout: "", stderr: "", interrupted: false, backgroundTaskId: taskId, ...(!initiallyBackgrounded ? { timedOutAfterMs: 120000 } : {}) } : { status: "async_launched", isAsync: true, agentId: taskId, description: "Investigate", prompt: "Investigate", outputFile: "task.output" }), + system("task_progress", { description: "Still working", usage: { total_tokens: 1, tool_uses: 1, duration_ms: 10 } }), + ...(toolName === "Agent" ? [ + { type: "assistant", parent_tool_use_id: toolId, message: { role: "assistant", content: [{ type: "tool_use", id: "nested-agent", name: "Agent", input: { description: "Nested reviewer", prompt: "Review" } }] } }, + { type: "assistant", parent_tool_use_id: "nested-agent", message: { role: "assistant", content: [{ type: "tool_use", id: "nested-bash", name: "Bash", input: { command: "Nested command" } }] } }, + ] : []), + { ...turnEnd, subtype: "error_during_execution", is_error: true, errors: ["Interrupted by user"] }, + ], + finish: [ + ...(toolName === "Agent" ? [ + { type: "user", parent_tool_use_id: "nested-agent", message: { role: "user", content: [{ type: "tool_result", tool_use_id: "nested-bash", content: "Nested command finished" }] } }, + { type: "user", parent_tool_use_id: toolId, message: { role: "user", content: [{ type: "tool_result", tool_use_id: "nested-agent", content: "Nested review finished" }] } }, + ] : []), + system("task_updated", { patch: { status: outcome === "stopped" ? "killed" : outcome, end_time: 10 } }), + system("task_notification", { status: outcome, summary: "Actual task result", output_file: "task.output" }), + system("task_notification", { status: outcome, summary: "Actual task result", output_file: "task.output" }), + toolResult(toolName === "Bash" ? { backgroundTaskId: taskId } : { status: "async_launched", isAsync: true, agentId: taskId }), + // A queued progress event must never revive a settled task. + system("task_progress", { description: "Stale progress", usage: { total_tokens: 1, tool_uses: 1, duration_ms: 11 } }), + turnEnd, + ], + rejected: [ + { type: "assistant", message: { role: "assistant", content: [{ type: "tool_use", id: "rejected-launch", name: toolName, input: { command: "rejected", run_in_background: true } }] } }, + { type: "user", tool_use_result: { backgroundTaskId: "rejected-task" }, message: { role: "user", content: [{ type: "tool_result", tool_use_id: "rejected-launch", is_error: true, content: "Permission denied" }] } }, + turnEnd, + ], + requested: [ + { type: "assistant", message: { role: "assistant", content: [{ type: "tool_use", id: "requested-only", name: toolName, input: { command: "requested", run_in_background: true } }] } }, + { type: "system", subtype: "task_started", task_id: "requested-task", tool_use_id: "requested-only", is_backgrounded: false, description: "Foreground execution" }, + turnEnd, + ], + }; + const { bridge, cleanup } = ultracodeFixtureBridge({ BACKGROUND_TASK_FIXTURE: JSON.stringify(fixture) }); + try { + bridge.writeCommand({ command: "create_session", cwd: process.cwd(), launch_settings: {} }); + const connected = await nextMatching(bridge, event => event.event === "connected"); + const collect = async (step: string) => { + bridge.writeCommand({ command: "prompt", session_id: connected.session_id, message_uuid: crypto.randomUUID(), chunks: [{ kind: "text", value: `fixture task ${step}` }] }); + const events: BridgeEnvelope[] = []; + while (true) { + const event = await bridge.nextEnvelope(5_000); + events.push(event); + if (event.event === "turn_complete" || event.event === "turn_error") return events; + } + }; + const fields = (events: BridgeEnvelope[]) => events.flatMap(event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "tool_call_update" && update.tool_call_update.tool_call_id === toolId ? [update.tool_call_update.fields] : []; + }); + const launchEvents = await collect("start"); + const launchUpdates = fields(launchEvents); + if (toolName === "Agent") { + for (const id of ["nested-agent", "nested-bash"]) { + const updates = launchEvents.flatMap(event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "tool_call_update" && update.tool_call_update.tool_call_id === id ? [update.tool_call_update.fields] : []; + }); + assert.ok(!updates.some(update => ["completed", "failed", "killed"].includes(update.status ?? "")), "foreground interruption must not finalize descendants of a background agent"); + } + } + assert.ok(launchUpdates.some(update => update.status === "detached"), `${toolName}: confirmed launch must detach`); + const detachment = launchUpdates.findIndex(update => update.status === "detached"); + assert.ok(launchUpdates.slice(detachment).every(update => update.status === undefined || update.status === "detached"), `${toolName}: launch/progress/interruption must not settle or reattach`); + const completionEvents = await collect("finish"); + const completionUpdates = fields(completionEvents); + if (toolName === "Agent") { + for (const id of ["nested-agent", "nested-bash"]) { + const updates = completionEvents.flatMap(event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "tool_call_update" && update.tool_call_update.tool_call_id === id ? [update.tool_call_update.fields] : []; + }); + assert.equal(updates.filter(update => update.status === "completed").length, 1); + assert.ok(updates.some(update => update.raw_output?.includes("finished")), "the real descendant result must remain deliverable"); + } + } + const inventory = completionEvents.flatMap(event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "task_state_update" ? update.tasks.filter(task => task.task_id === taskId) : []; + }); + assert.equal(inventory.at(-1)?.status, "completed", "stale progress and duplicate launch acknowledgements must not revive task inventory"); + assert.equal(completionUpdates.filter(update => update.status === (outcome === "stopped" ? "killed" : outcome)).length, 1, `${toolName}: duplicate notifications must produce one terminal outcome on the original tool`); + assert.ok(!completionUpdates.slice(completionUpdates.findIndex(update => update.status === (outcome === "stopped" ? "killed" : outcome))).some(update => update.status === "in_progress" || update.status === "detached"), `${toolName}: stale progress must not revive completion`); + assert.ok(completionUpdates.some(update => update.raw_output?.includes("Actual task result")), `${toolName}: render actual completion, not the launch placeholder`); + for (const step of ["rejected", "requested"]) { + const id = step === "rejected" ? "rejected-launch" : "requested-only"; + const events = await collect(step); + const updates = events.flatMap(event => { + const update = event.update as SessionUpdate | undefined; + return update?.type === "tool_call_update" && update.tool_call_update.tool_call_id === id ? [update.tool_call_update.fields] : []; + }); + assert.ok(!updates.some(update => update.status === "detached"), "requesting background execution or rejecting a launch must not detach"); + if (step === "rejected") assert.ok(updates.some(update => update.status === "failed")); + else assert.ok(updates.some(update => update.status === "in_progress")); + } + } finally { + await cleanup(); + } + } + } + } +}); + +test("bridge announces an autonomous completion reply before streaming text without another prompt", async () => { + const response = (event: BridgeEnvelope) => ({ type: "stream_event", uuid: crypto.randomUUID(), parent_tool_use_id: null, event }); + const turnEnd = { type: "result", subtype: "success", is_error: false, duration_ms: 1, num_turns: 1, result: "", usage: {}, modelUsage: {}, permission_denials: [] }; + const fixture = { start: [ + { type: "assistant", message: { role: "assistant", content: [{ type: "tool_use", id: "independent-tool", name: "Bash", input: { command: "background work" } }] } }, + { type: "user", tool_use_result: { backgroundTaskId: "independent-task" }, message: { role: "user", content: [{ type: "tool_result", tool_use_id: "independent-tool", content: "Launch acknowledgement" }] } }, + turnEnd, + { type: "system", subtype: "task_notification", task_id: "independent-task", tool_use_id: "independent-tool", status: "completed", summary: "Background command completed" }, + response({ type: "message_start", message: { id: "autonomous-response" } }), + response({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }), + response({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "The" } }), + response({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " task finished completely." } }), + response({ type: "message_stop" }), + { ...turnEnd, origin: { kind: "task-notification" } }, + ] }; + const { bridge, cleanup } = ultracodeFixtureBridge({ BACKGROUND_TASK_FIXTURE: JSON.stringify(fixture) }); + try { + bridge.writeCommand({ command: "create_session", cwd: process.cwd(), launch_settings: {} }); + const connected = await nextMatching(bridge, event => event.event === "connected"); + bridge.writeCommand({ command: "prompt", session_id: connected.session_id, message_uuid: crypto.randomUUID(), chunks: [{ kind: "text", value: "fixture task start" }] }); + await nextMatching(bridge, event => event.event === "turn_complete"); + const events: BridgeEnvelope[] = []; + while (true) { + const event = await bridge.nextEnvelope(5_000); + events.push(event); + if (event.event === "turn_complete") break; + } + const updates = events.flatMap(event => event.update ? [event.update as SessionUpdate] : []); + const activity = updates.findIndex(update => update.type === "agent_response_started"); + const text = updates.findIndex(update => update.type === "agent_message_chunk"); + assert.ok(activity >= 0 && activity < text, "main response activation must precede text on NDJSON"); + assert.deepEqual(updates.flatMap(update => update.type === "agent_message_chunk" ? [update.content] : []), [{ type: "text", text: "The" }, { type: "text", text: " task finished completely." }]); + assert.ok(!events.some(event => event.event === "user_message_started"), "an autonomous reply must not fabricate another user turn"); + assert.equal(updates.filter(update => update.type === "tool_call_update" && update.tool_call_update.fields.status === "completed").length, 1); + } finally { + await cleanup(); + } +}); + +test("resume restores background acknowledgements and routes task-only outcomes to their original tools", async () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), "claude-rs-background-resume-"))); + const cwd = process.cwd(); + const configDir = join(directory, "config"); + const projectDir = join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); + mkdirSync(projectDir, { recursive: true }); + const sessionId = "22222222-2222-4222-8222-222222222222"; + const records: BridgeEnvelope[] = []; + const append = (type: string, message: unknown, extra: BridgeEnvelope = {}) => { + records.push({ uuid: `record-${records.length}`, parentUuid: records.at(-1)?.uuid ?? null, type, message, sessionId, cwd, timestamp: "2026-10-06T10:55:30.787Z", ...extra }); + }; + append("user", { role: "user", content: "Launch background work" }); + append("assistant", { id: "launch", role: "assistant", content: [ + { type: "tool_use", id: "completed-shell", name: "Bash", input: { command: "saved command", run_in_background: true } }, + { type: "tool_use", id: "pending-agent", name: "Agent", input: { description: "Saved agent", prompt: "Work", run_in_background: true } }, + ] }); + append("user", { role: "user", content: [{ type: "tool_result", tool_use_id: "completed-shell", content: "Launch acknowledgement" }] }, { toolUseResult: { stdout: "partial stdout", stderr: "", backgroundTaskId: "saved-shell" } }); + append("user", { role: "user", content: [{ type: "tool_result", tool_use_id: "pending-agent", content: "Agent launch acknowledgement" }] }, { toolUseResult: { status: "async_launched", isAsync: true, agentId: "saved-agent", description: "Saved agent", outputFile: "saved.output" } }); + append("user", { role: "user", content: "saved-shellcompletedSaved command result" }, { origin: { kind: "task-notification", producer: "session-task" } }); + append("assistant", { id: "after-launch", role: "assistant", content: [{ type: "text", text: "Foreground continued" }] }); + const transcriptPath = join(projectDir, `${sessionId}.jsonl`); + const transcript = `${records.map(record => JSON.stringify(record)).join("\n")}\n`; + writeFileSync(transcriptPath, transcript); + const completion = { type: "system", subtype: "task_notification", task_id: "saved-agent", status: "stopped", summary: "Saved agent stopped", output_file: "saved.output" }; + const { bridge, cleanup } = ultracodeFixtureBridge({ + CLAUDE_CONFIG_DIR: configDir, CLAUDE_CODE_PROJECT_DIR_NAME: undefined, RESUME_TRANSCRIPT_FIXTURE: "1", + STARTUP_SESSIONS_FIXTURE: JSON.stringify([{ sessionId, cwd, lastModified: 1 }]), + BACKGROUND_TASK_FIXTURE: JSON.stringify({ finish: [completion, completion, + { type: "system", subtype: "task_progress", task_id: "saved-agent", description: "Stale resumed progress" }, + { type: "result", subtype: "success", is_error: false, duration_ms: 1, num_turns: 1, result: "Finished", usage: {}, modelUsage: {}, permission_denials: [] }, + ] }), + }); + try { + bridge.writeCommand({ command: "resume_session", session_id: sessionId, launch_settings: {} }); + const connected = await nextMatching(bridge, event => event.event === "connected" || event.event === "slash_command_error"); + assertProtocolEvent(connected, "connected"); + const history = connected.history_updates as SessionUpdate[]; + const statuses = (id: string) => history.flatMap(update => update.type === "tool_call" && update.tool_call.tool_call_id === id ? [update.tool_call.status] + : update.type === "tool_call_update" && update.tool_call_update.tool_call_id === id && update.tool_call_update.fields.status ? [update.tool_call_update.fields.status] : []); + assert.deepEqual(statuses("completed-shell"), ["in_progress", "detached", "completed"]); + assert.deepEqual(statuses("pending-agent"), ["in_progress", "detached"]); + const shellResult = history.slice().reverse().find(update => update.type === "tool_call_update" && update.tool_call_update.tool_call_id === "completed-shell"); + assert.ok(shellResult?.type === "tool_call_update"); + assert.match(shellResult.tool_call_update.fields.raw_output ?? "", /partial stdout/); + assert.match(shellResult.tool_call_update.fields.raw_output ?? "", /Saved command result/); + assert.ok(!history.some(update => update.type === "user_message_chunk" && update.content.type === "text" && update.content.text.includes(""))); + bridge.writeCommand({ command: "prompt", session_id: sessionId, message_uuid: crypto.randomUUID(), chunks: [{ kind: "text", value: "fixture task finish" }] }); + const results: SessionUpdate[] = []; + while (true) { + const event = await bridge.nextEnvelope(5_000); + if (event.event === "session_update") results.push(event.update as SessionUpdate); + if (event.event === "turn_complete" || event.event === "turn_error") break; + } + const outcomes = results.filter(update => update.type === "tool_call_update" && update.tool_call_update.tool_call_id === "pending-agent"); + assert.equal(outcomes.length, 1, "duplicate and stale resumed notifications must not create another result"); + assert.ok(outcomes[0]?.type === "tool_call_update"); + assert.equal(outcomes[0].tool_call_update.fields.status, "killed"); + assert.equal(outcomes[0].tool_call_update.fields.raw_output, "Saved agent stopped"); + assert.ok(!results.some(update => update.type === "tool_call"), "resume must not invent a replacement Agent for an existing shell or agent"); + assert.equal(readFileSync(transcriptPath, "utf8"), transcript); + } finally { + await cleanup(); + assert.equal(dirname(directory), realpathSync(tmpdir())); + rmSync(directory, { recursive: true, force: true }); + } +}); + test("bridge process emits connection_failed for malformed JSON", async () => { const bridge = new SpawnedBridge(); try { @@ -417,6 +639,10 @@ function ultracodeFixtureBridge(env: NodeJS.ProcessEnv = {}): { bridge: SpawnedB void (async () => { for await (const message of prompt) { const text = message.message?.content?.filter(block => block.type === "text").map(block => block.text).join("").trim(); + if (process.env.BACKGROUND_TASK_FIXTURE && text.startsWith("fixture task ")) { + const steps = JSON.parse(process.env.BACKGROUND_TASK_FIXTURE); + for (const event of steps[text.slice("fixture task ".length)] ?? []) push(event); + } if (commandFixture && text === "/fixture-refresh-commands") { push({ type: "system", subtype: "commands_changed", commands: currentCommands }); } @@ -580,8 +806,11 @@ test("resume command restores the screenshot conversation without internal task const tools = updates.filter(update => update.type === "tool_call"); assert.equal(tools.length, 1); assert.equal(tools[0].tool_call.tool_call_id, "read-source"); - assert.equal(tools[0].tool_call.status, "completed"); - assert.equal(tools[0].tool_call.raw_output, "legitimate XML tool output"); + assert.equal(tools[0].tool_call.status, "in_progress"); + const result = updates.find(update => update.type === "tool_call_update" && update.tool_call_update.tool_call_id === "read-source"); + assert.ok(result?.type === "tool_call_update"); + assert.equal(result.tool_call_update.fields.status, "completed"); + assert.equal(result.tool_call_update.fields.raw_output, "legitimate XML tool output"); assert.equal(readFileSync(transcriptPath, "utf8"), transcript); } finally { await cleanup(); diff --git a/agent-sdk/src/bridge.test.ts b/agent-sdk/src/bridge.test.ts index 18f41f24..3a0e8aab 100644 --- a/agent-sdk/src/bridge.test.ts +++ b/agent-sdk/src/bridge.test.ts @@ -292,6 +292,33 @@ function activityPhases(events: ReturnType): string[ }); } +test("background completion replies announce live response ownership before their first text", () => { + const session = makeSessionState(); + const events = captureBridgeEvents(() => { + emitToolCall(session, "completion-tool", "Bash", { command: "long command" }); + emitToolResultUpdate(session, "completion-tool", false, "Launch acknowledgement", { backgroundTaskId: "completion-task" }); + handleResultMessage(session, { type: "result", subtype: "success", result: "Foreground reply", is_error: false }); + handleSdkMessage(session, { type: "system", subtype: "task_notification", task_id: "completion-task", tool_use_id: "completion-tool", status: "completed", summary: "Background command completed" } as unknown as import("@anthropic-ai/claude-agent-sdk").SDKMessage); + activityStream(session, { type: "message_start", message: { id: "child-response" } }, "detached-agent"); + activityStream(session, { type: "message_stop" }, "detached-agent"); + activityStream(session, { type: "message_start", message: { id: "completion-response" } }); + activityStream(session, { type: "message_start", message: { id: "completion-response" } }); + activityStream(session, { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }); + activityStream(session, { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "The" } }); + activityStream(session, { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: " background task finished completely." } }); + activityStream(session, { type: "message_stop" }); + handleResultMessage(session, { type: "result", subtype: "success", result: "The background task finished completely.", is_error: false, origin: { kind: "task-notification" } }); + }); + const updates = events.flatMap(event => event.update ? [event.update as Record] : []); + const start = updates.findIndex(update => update.type === "agent_response_started"); + const text = updates.findIndex(update => update.type === "agent_message_chunk"); + assert.ok(start >= 0 && start < text, "live response ownership must precede text insertion eligibility"); + assert.deepEqual(activityPhases(events), []); + assert.equal(updates.filter(update => update.type === "agent_response_started").length, 1); + assert.deepEqual(updates.filter(update => update.type === "agent_message_chunk").map(update => (update.content as Record).text), ["The", " background task finished completely."]); + assert.equal(session.mainAgentResponse, undefined); +}); + test("live thinking starts at the block boundary with text, omitted text, or redacted content", () => { for (const block of [{ type: "thinking", thinking: "" }, { type: "thinking", thinking: "summary" }, { type: "redacted_thinking", data: "redacted" }]) { const session = makeSessionState(); @@ -3834,7 +3861,7 @@ test("background Bash remains open across turn interruption until its final task assert.equal( session.toolCalls.get("tool-bash-background")?.status, - "in_progress", + "detached", ); assert.equal( session.taskToolUseIds.get("bash-task-1"), @@ -4324,7 +4351,7 @@ test("handleTaskSystemMessage applies task_updated description patches to the li tool_call_update: { tool_call_id: "tool-1", fields: { - status: "in_progress", + status: "detached", raw_output: "Refining the migration plan", content: [ { @@ -4438,7 +4465,7 @@ test("Monitor launch links task id and accepts task lifecycle updates", () => { assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor"); assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1"); - assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress"); + assert.equal(session.toolCalls.get("tool-monitor")?.status, "detached"); captureBridgeEvents(() => { handleTaskSystemMessage(session, "task_updated", { @@ -4452,14 +4479,14 @@ test("Monitor launch links task id and accepts task lifecycle updates", () => { }); const toolCall = session.toolCalls.get("tool-monitor"); - assert.equal(toolCall?.status, "in_progress"); + assert.equal(toolCall?.status, "detached"); assert.equal(toolCall?.raw_output, "Monitor observed deploy log output"); assert.equal(toolCall?.task_metadata?.is_backgrounded, true); assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor"); assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1"); }); -test("Monitor launch stays in progress after successful assistant turn until final lifecycle notification", () => { +test("Monitor launch stays detached after successful assistant turn until final lifecycle notification", () => { const session = makeSessionState(); captureBridgeEvents(() => { @@ -4481,7 +4508,7 @@ test("Monitor launch stays in progress after successful assistant turn until fin }); }); - assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress"); + assert.equal(session.toolCalls.get("tool-monitor")?.status, "detached"); assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor"); assert.equal(session.taskIdsByToolUseId.get("tool-monitor"), "monitor-1"); @@ -9491,7 +9518,7 @@ test("handleResultMessage emits repeated turn_complete while background work rem }, ], ); - assert.equal(session.toolCalls.get("tool-monitor")?.status, "in_progress"); + assert.equal(session.toolCalls.get("tool-monitor")?.status, "detached"); assert.equal(session.taskToolUseIds.get("monitor-1"), "tool-monitor"); }); diff --git a/agent-sdk/src/bridge/activity.ts b/agent-sdk/src/bridge/activity.ts index dc1a7d56..9e6f1974 100644 --- a/agent-sdk/src/bridge/activity.ts +++ b/agent-sdk/src/bridge/activity.ts @@ -26,6 +26,7 @@ export function observeMainAgentStream( if (session.mainAgentResponse?.messageId === message.id) return; resetMainAgentActivity(session); session.mainAgentResponse = { messageId: message.id }; + emitSessionUpdate(session.sessionId, { type: "agent_response_started" }); return; } const response = session.mainAgentResponse; diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts index 446d607b..78283d8d 100644 --- a/agent-sdk/src/bridge/history.test.ts +++ b/agent-sdk/src/bridge/history.test.ts @@ -60,8 +60,10 @@ test("resume display restores compaction segments once and preserves SDK branch const tools = result.updates.filter((update: { type: string }) => update.type === "tool_call"); assert.equal(tools.length, 1); assert.equal(tools[0].tool_call.tool_call_id, "read-1"); - assert.equal(tools[0].tool_call.status, "completed"); - assert.equal(tools[0].tool_call.raw_output, "file contents"); + assert.equal(tools[0].tool_call.status, "in_progress"); + const resultUpdate = result.updates.find((update: { type: string; tool_call_update?: { tool_call_id: string } }) => update.type === "tool_call_update" && update.tool_call_update?.tool_call_id === "read-1"); + assert.equal(resultUpdate.tool_call_update.fields.status, "completed"); + assert.equal(resultUpdate.tool_call_update.fields.raw_output, "file contents"); assert.equal(readFileSync(transcript, "utf8"), before); } finally { assert.equal(dirname(directory), realpathSync(tmpdir())); diff --git a/agent-sdk/src/bridge/history.ts b/agent-sdk/src/bridge/history.ts index bdf9a024..c7648342 100644 --- a/agent-sdk/src/bridge/history.ts +++ b/agent-sdk/src/bridge/history.ts @@ -8,18 +8,22 @@ import type { SessionStoreEntry, } from "@anthropic-ai/claude-agent-sdk"; import type { - Json, SessionListEntry, SessionUpdate, TaskItem, - TaskStatus, ToolCall, } from "../types.js"; import { asRecordOrNull } from "./shared.js"; +import { applyFieldsToBase, normalizeToolCallUpdateFields, toolAcceptsTaskLifecycle, toolNameFromMeta } from "./tool_calls.js"; +import { taskLifecyclePatch, upsertTask } from "./tasks.js"; +import { linkTaskToolUse } from "./task_links.js"; +import type { SessionState } from "./session_lifecycle.js"; +import { taskSystemToolFields } from "./message_handlers.js"; import { applyToolNonExecutionMetadata, TOOL_RESULT_TYPES, buildToolResultFields, + backgroundToolLaunchTaskIdFromResult, createToolCall, isToolSearchToolName, isToolSearchToolResultType, @@ -55,7 +59,13 @@ export async function getSessionTranscriptMessages( }; // Import streams the complete file, including history before the SDK's large-file cutoff. await importSessionToStore(sessionId, store, { ...options, includeSubagents: false }); - return getSessionMessages(sessionId, { ...options, includeSystemMessages: true, sessionStore: store }); + const messages = await getSessionMessages(sessionId, { ...options, includeSystemMessages: true, sessionStore: store }); + const records = new Map(entries.map(entry => [entry.uuid, entry])); + return messages.map(message => { + const record = records.get(message.uuid); + const rawResult = record?.toolUseResult ?? record?.tool_use_result; + return rawResult === undefined ? message : { ...message, tool_use_result: rawResult }; + }); } function nonEmptyTrimmed(value: unknown): string | undefined { @@ -79,112 +89,11 @@ function messageCandidates(raw: unknown): Record[] { return candidates; } -function jsonValue(value: unknown): Json | undefined { - if (value === undefined) { - return undefined; - } - try { - const text = JSON.stringify(value); - if (text === undefined) { - return undefined; - } - return JSON.parse(text) as Json; - } catch { - return undefined; - } -} - -function mergeTaskMetadata( - existing: Json | undefined, - patch: Record | undefined, -): Json | undefined { - if (!patch) { - return existing; - } - const existingRecord = - existing && typeof existing === "object" && !Array.isArray(existing) - ? { ...(existing as Record) } - : {}; - for (const [key, value] of Object.entries(patch)) { - if (value === null) { - delete existingRecord[key]; - } else { - existingRecord[key] = value; - } - } - return Object.keys(existingRecord).length > 0 ? existingRecord : null; -} - -function normalizeLifecycleTaskStatus(value: unknown): TaskStatus | undefined { - switch (value) { - case "pending": - return "pending"; - case "running": - case "in_progress": - return "in_progress"; - case "completed": - case "failed": - case "killed": - case "stopped": - return "completed"; - default: - return undefined; - } -} - -function taskSystemMetadata( - msg: Record, - patch: Record | undefined, -): Record | undefined { - const metadata: Record = {}; - const copyValue = ( - from: Record | undefined, - key: string, - ): void => { - if (!from || !Object.hasOwn(from, key)) { - return; - } - const value = jsonValue(from[key]); - if (value !== undefined) { - metadata[key] = value; - } - }; - - for (const key of [ - "error", - "is_backgrounded", - "spawn_depth", - "request_id", - "subagent_type", - "task_description", - "task_type", - "workflow_name", - "prompt", - "output_file", - "summary", - "end_time", - "total_paused_ms", - ]) { - copyValue(msg, key); - copyValue(patch, key); - } - const terminalStatus = - nonEmptyTrimmed(msg.status) ?? nonEmptyTrimmed(patch?.status); - if ( - terminalStatus === "completed" || - terminalStatus === "failed" || - terminalStatus === "killed" || - terminalStatus === "stopped" - ) { - metadata.terminal_status = terminalStatus; - } - return Object.keys(metadata).length > 0 ? metadata : undefined; -} - function pushResumeTaskSystemUpdate( updates: SessionUpdate[], tasksById: Map, taskToolUseIds: Map, + toolCalls: Map, msg: Record, ): boolean { const subtype = nonEmptyTrimmed(msg.subtype); @@ -205,66 +114,22 @@ function pushResumeTaskSystemUpdate( if (explicitToolUseId) { taskToolUseIds.set(taskId, explicitToolUseId); } + const toolUseId = explicitToolUseId ?? taskToolUseIds.get(taskId); + const tool = toolUseId ? toolCalls.get(toolUseId) : undefined; + if (tool && toolUseId && toolAcceptsTaskLifecycle(tool)) { + const fields = taskSystemToolFields(tool, subtype, msg); + if (!normalizeToolCallUpdateFields(tool, fields ?? {}, subtype as "task_started" | "task_progress" | "task_updated" | "task_notification")) return true; + if (fields) { + applyFieldsToBase(tool, fields); + updates.push({ type: "tool_call_update", tool_call_update: { tool_call_id: toolUseId, fields } }); + } + } - const existing = tasksById.get(taskId); - const patch = asRecordOrNull(msg.patch) ?? undefined; - const status = - normalizeLifecycleTaskStatus(msg.status) ?? - normalizeLifecycleTaskStatus(patch?.status) ?? - (subtype === "task_started" || subtype === "task_progress" - ? "in_progress" - : undefined); - const description = - nonEmptyTrimmed(patch?.description) ?? - nonEmptyTrimmed(msg.description) ?? - nonEmptyTrimmed(msg.summary); - const activeForm = - nonEmptyTrimmed(patch?.activeForm) ?? nonEmptyTrimmed(patch?.active_form); - const subject = - nonEmptyTrimmed(patch?.subject) ?? - nonEmptyTrimmed(msg.subject) ?? - existing?.subject ?? - nonEmptyTrimmed(msg.workflow_name) ?? - nonEmptyTrimmed(msg.task_description) ?? - description ?? - taskId; - const metadata = mergeTaskMetadata( - existing?.metadata, - taskSystemMetadata(msg, patch), - ); - const sourceToolCallId = - taskToolUseIds.get(taskId) ?? existing?.source_tool_call_id; - - const task: TaskItem = { - task_id: taskId, - subject, - ...(description !== undefined - ? { description } - : existing?.description !== undefined - ? { description: existing.description } - : {}), - ...(activeForm !== undefined - ? { active_form: activeForm } - : existing?.active_form !== undefined - ? { active_form: existing.active_form } - : {}), - status: status ?? existing?.status ?? "pending", - ...(existing?.owner !== undefined ? { owner: existing.owner } : {}), - blocks: existing ? [...existing.blocks] : [], - blocked_by: existing ? [...existing.blocked_by] : [], - ...(metadata !== undefined ? { metadata } : {}), - ...(sourceToolCallId !== undefined - ? { source_tool_call_id: sourceToolCallId } - : {}), - }; - tasksById.set(taskId, task); - updates.push({ - type: "task_state_update", - source: "task_lifecycle", - tasks: [task], - removed_task_ids: [], - is_complete_snapshot: false, - }); + const patch = taskLifecyclePatch(tasksById.get(taskId), subtype, msg, toolUseId); + if (patch) { + const task = upsertTask({ tasksById, taskOrder: [] }, patch); + updates.push({ type: "task_state_update", source: "task_lifecycle", tasks: [task], removed_task_ids: [], is_complete_snapshot: false }); + } return true; } @@ -318,7 +183,7 @@ function pushResumeToolUse( toolCall.source_message_uuid = sourceMessageUuid; } toolCalls.set(toolUseId, toolCall); - updates.push({ type: "tool_call", tool_call: toolCall }); + updates.push({ type: "tool_call", tool_call: structuredClone(toolCall) }); } function pushResumeToolResult( @@ -330,7 +195,10 @@ function pushResumeToolResult( string, import("../types.js").ToolNonExecutionMetadata >, - sourceMessageUuid?: string, + sourceMessageUuid: string | undefined, + rawResult: unknown, + taskToolUseIds: Map, + tasksById: Map, ): void { const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : ""; @@ -347,8 +215,19 @@ function pushResumeToolResult( } const isError = Boolean(block.is_error); const base = toolCalls.get(toolUseId); - const fields = buildToolResultFields(isError, block.content, base, block); + const fields = buildToolResultFields(isError, block.content, base, rawResult ?? block); applyToolNonExecutionMetadata(fields, nonExecutionByToolUseId.get(toolUseId)); + if (!normalizeToolCallUpdateFields(base, fields, "result")) return; + const taskId = !isError ? backgroundToolLaunchTaskIdFromResult( + toolNameFromMeta(base?.meta) ?? "", rawResult ?? block, block.content, + ) : undefined; + if (taskId) { + taskToolUseIds.set(taskId, toolUseId); + const patch = taskLifecyclePatch(tasksById.get(taskId), "task_started", { + task_id: taskId, description: base?.title, is_backgrounded: true, + }, toolUseId); + if (patch) updates.push({ type: "task_state_update", source: "task_lifecycle", tasks: [upsertTask({ tasksById, taskOrder: [] }, patch)], removed_task_ids: [], is_complete_snapshot: false }); + } updates.push({ type: "tool_call_update", tool_call_update: { @@ -361,16 +240,7 @@ function pushResumeToolResult( if (!base) { return; } - base.status = fields.status ?? base.status; - if (fields.raw_output) { - base.raw_output = fields.raw_output; - } - if (fields.content) { - base.content = fields.content; - } - if (fields.output_metadata) { - base.output_metadata = fields.output_metadata; - } + applyFieldsToBase(base, fields); } function summaryFromSession(info: SDKSessionInfo): string { @@ -435,11 +305,16 @@ export function mapSessionMessagesToUpdates( const origin = asRecordOrNull(record?.origin); // Background-task inputs belong to Claude's context, not the visible user transcript. // Peer and scheduled messages carry a subkind and remain eligible for display. - if (record?.isCompactSummary === true || ( + if (record?.isCompactSummary === true) continue; + if ( entry.type === "user" && origin?.kind === "task-notification" && origin.subkind === undefined && (origin.producer === undefined || origin.producer === "session-task") - )) continue; + ) { + const notification = resumedTaskNotification(entry.message); + if (notification) pushResumeTaskSystemUpdate(updates, tasksById, taskToolUseIds, toolCalls, notification); + continue; + } const fallbackRole = entry.type === "assistant" ? "assistant" : "user"; const entrySourceMessageUuid = typeof entry.uuid === "string" ? entry.uuid : undefined; @@ -456,6 +331,7 @@ export function mapSessionMessagesToUpdates( updates, tasksById, taskToolUseIds, + toolCalls, message, ) ) { @@ -521,6 +397,9 @@ export function mapSessionMessagesToUpdates( block, nonExecutionByToolUseId, sourceMessageUuid, + record?.tool_use_result ?? record?.toolUseResult, + taskToolUseIds, + tasksById, ); continue; } @@ -535,3 +414,38 @@ export function mapSessionMessagesToUpdates( return updates; } + +/** Restore runtime correlations from the same chronological updates shown by the UI. */ +export function restoreResumedToolState(session: SessionState, updates: SessionUpdate[]): void { + for (const update of updates) { + if (update.type === "tool_call") session.toolCalls.set(update.tool_call.tool_call_id, structuredClone(update.tool_call)); + if (update.type === "tool_call_update") { + const base = session.toolCalls.get(update.tool_call_update.tool_call_id); + if (base) applyFieldsToBase(base, structuredClone(update.tool_call_update.fields)); + } + if (update.type === "task_state_update") { + for (const task of update.tasks) upsertTask(session, structuredClone(task)); + for (const id of update.removed_task_ids) session.tasksById.delete(id); + } + } + for (const task of session.tasksById.values()) { + const tool = task.source_tool_call_id ? session.toolCalls.get(task.source_tool_call_id) : undefined; + if (tool && !["completed", "failed", "killed"].includes(tool.status)) linkTaskToolUse(session, task.task_id, tool.tool_call_id); + } +} + +function resumedTaskNotification(raw: unknown): Record | undefined { + for (const message of messageCandidates(raw)) { + const content = typeof message.content === "string" ? message.content : Array.isArray(message.content) + ? message.content.map(block => asRecordOrNull(block)?.text ?? "").join("\n") : ""; + const body = content.match(/([\s\S]*?)<\/task-notification>/)?.[1]; + if (!body) continue; + const field = (name: string) => body.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`))?.[1]?.trim(); + const taskId = field("task-id"); + const status = field("status"); + if (taskId && (status === "completed" || status === "failed" || status === "stopped")) { + return { subtype: "task_notification", task_id: taskId, status, tool_use_id: field("tool-use-id"), summary: field("summary"), output_file: field("output-file") }; + } + } + return undefined; +} diff --git a/agent-sdk/src/bridge/message_handlers.ts b/agent-sdk/src/bridge/message_handlers.ts index 05eed3a6..b37b150f 100644 --- a/agent-sdk/src/bridge/message_handlers.ts +++ b/agent-sdk/src/bridge/message_handlers.ts @@ -13,6 +13,7 @@ import type { TerminalReason, TranscriptRetractionReason, ToolCallUpdateFields, + ToolCall, } from "../types.js"; import { asRecordOrNull } from "./shared.js"; import { @@ -45,10 +46,10 @@ import { resolveTaskToolUseId, defersTaskNotificationCompletion, toolAcceptsTaskLifecycle, - toolAcceptsTerminalTaskNotification, toolPreservesTaskNotificationOutput, taskProgressText, taskUpdatedFields, + normalizeToolCallUpdateFields, type ToolCorrelationMetadata, } from "./tool_calls.js"; import { @@ -706,6 +707,98 @@ export function contentFromPrompt( return content; } +export function taskSystemToolFields( + toolCall: ToolCall, + subtype: string, + msg: Record, +): ToolCallUpdateFields | undefined { + const messageTaskMetadata = sdkTaskMetadata(msg); + if (subtype === "task_started") { + const description = + typeof msg.description === "string" ? msg.description : ""; + if (!description) { + return undefined; + } + const fields: ToolCallUpdateFields = { + status: "in_progress", + ...(!(toolPreservesTaskNotificationOutput(toolCall) && toolCall.raw_output) ? { raw_output: description, + content: [ + { type: "content", content: { type: "text", text: description } }, + ] } : {}), + ...(messageTaskMetadata ? { task_metadata: messageTaskMetadata } : {}), + }; + return Object.keys(fields).length === 1 && fields.status === toolCall.status ? undefined : fields; + } + + if (subtype === "task_progress") { + const progress = taskProgressText(msg); + if (!progress) { + return undefined; + } + const fields: ToolCallUpdateFields = { + status: "in_progress", + ...(!(toolPreservesTaskNotificationOutput(toolCall) && toolCall.raw_output) ? { + raw_output: progress, + content: [{ type: "content", content: { type: "text", text: progress } }], + } : {}), + ...(messageTaskMetadata ? { task_metadata: messageTaskMetadata } : {}), + }; + return fields; + } + + if (subtype === "task_updated") { + const fields = taskUpdatedFields(msg); + if (messageTaskMetadata) { + fields.task_metadata = { + ...(fields.task_metadata ?? {}), + ...messageTaskMetadata, + }; + } + if (Object.keys(fields).length === 0) { + return undefined; + } + if (toolCall.status === "detached" && isTerminalToolStatus(fields.status)) { + // Completion patches precede the payload-bearing notification. Keep the + // linkage and independent state until the final result arrives. + fields.status = "detached"; + } + return fields; + } + + const status = typeof msg.status === "string" ? msg.status : ""; + const summary = typeof msg.summary === "string" ? msg.summary : ""; + const finalStatus = + status === "completed" + ? "completed" + : status === "stopped" + ? "killed" + : "failed"; + const deferCompletion = + finalStatus === "completed" && toolCall.status !== "detached" && defersTaskNotificationCompletion(toolCall); + const fields: ToolCallUpdateFields = deferCompletion + ? {} + : { status: finalStatus }; + if (messageTaskMetadata) { + fields.task_metadata = messageTaskMetadata; + } + if (summary && (!toolPreservesTaskNotificationOutput(toolCall) || toolCall.status === "detached")) { + const output = toolPreservesTaskNotificationOutput(toolCall) && toolCall.raw_output + ? `${toolCall.raw_output}\n${summary}` : summary; + fields.raw_output = output; + fields.content = [ + { type: "content", content: { type: "text", text: output } }, + ]; + } + const contentWithResourceLinks = appendResourceLinks( + fields.content, + msg.resource_links, + ); + if (contentWithResourceLinks !== undefined) { + fields.content = contentWithResourceLinks; + } + return Object.keys(fields).length > 0 ? fields : undefined; +} + export function handleTaskSystemMessage( session: SessionState, subtype: string, @@ -723,7 +816,6 @@ export function handleTaskSystemMessage( const taskId = typeof msg.task_id === "string" ? msg.task_id : ""; const explicitToolUseId = typeof msg.tool_use_id === "string" ? msg.tool_use_id : ""; - const messageTaskMetadata = sdkTaskMetadata(msg); if (taskId && explicitToolUseId) { linkTaskToolUse(session, taskId, explicitToolUseId); } @@ -783,15 +875,14 @@ export function handleTaskSystemMessage( const toolCall = ensureToolCallVisible(session, toolUseId, "Agent", {}); const acceptsLifecycle = toolAcceptsTaskLifecycle(toolCall); - const acceptsTerminalNotification = - subtype === "task_notification" && - toolAcceptsTerminalTaskNotification(toolCall); - if (!acceptsLifecycle && !acceptsTerminalNotification) { + if (!acceptsLifecycle) { if (taskId) { unlinkTaskToolUse(session, taskId); } return true; } + const fields = taskSystemToolFields(toolCall, subtype, msg); + if (!normalizeToolCallUpdateFields(toolCall, fields ?? {}, subtype as "task_started" | "task_progress" | "task_updated" | "task_notification")) return true; applyTaskLifecycleState(session, subtype, msg); const taskDuration = elapsedNumber(asRecordOrNull(msg.usage)?.duration_ms); if (taskDuration !== undefined && Number.isSafeInteger(Math.round(taskDuration))) { @@ -806,107 +897,9 @@ export function handleTaskSystemMessage( ); } - if (subtype === "task_started") { - const description = - typeof msg.description === "string" ? msg.description : ""; - if (!description) { - return true; - } - const fields: ToolCallUpdateFields = { - status: "in_progress", - raw_output: description, - content: [ - { type: "content", content: { type: "text", text: description } }, - ], - ...(messageTaskMetadata ? { task_metadata: messageTaskMetadata } : {}), - }; - emitToolCallUpdate(session, toolUseId, fields, "task_started"); - return true; - } - - if (subtype === "task_progress") { - const progress = taskProgressText(msg); - if (!progress) { - return true; - } - const fields: ToolCallUpdateFields = { - status: "in_progress", - raw_output: progress, - content: [{ type: "content", content: { type: "text", text: progress } }], - ...(messageTaskMetadata ? { task_metadata: messageTaskMetadata } : {}), - }; - emitToolCallUpdate(session, toolUseId, fields, "task_progress"); - return true; - } - - if (subtype === "task_updated") { - const fields = taskUpdatedFields(msg); - if (messageTaskMetadata) { - fields.task_metadata = { - ...(fields.task_metadata ?? {}), - ...messageTaskMetadata, - }; - } - if (Object.keys(fields).length === 0) { - return true; - } - bridgeLogger.debug({ - target: LOG_TARGETS.APP_TOOL, - eventName: "task_updated_emitted", - message: "task update mapped to tool call update", - outcome: "success", - sessionId: session.sessionId, - toolCallId: toolUseId, - fields: { - task_id: taskId, - mapped_status: fields.status, - has_description: fields.content !== undefined, - has_error: Boolean(fields.task_metadata?.error), - is_backgrounded: fields.task_metadata?.is_backgrounded, - }, - }); - emitToolCallUpdate(session, toolUseId, fields, "task_updated"); - if (taskId && isTerminalToolStatus(fields.status)) { - unlinkTaskToolUse(session, taskId); - } - return true; - } - - const status = typeof msg.status === "string" ? msg.status : ""; - const summary = typeof msg.summary === "string" ? msg.summary : ""; - const finalStatus = - status === "completed" - ? "completed" - : status === "stopped" - ? "killed" - : "failed"; - const deferCompletion = - finalStatus === "completed" && defersTaskNotificationCompletion(toolCall); - const fields: ToolCallUpdateFields = deferCompletion - ? {} - : { status: finalStatus }; - if (messageTaskMetadata) { - fields.task_metadata = messageTaskMetadata; - } - if (summary && !toolPreservesTaskNotificationOutput(toolCall)) { - fields.raw_output = summary; - fields.content = [ - { type: "content", content: { type: "text", text: summary } }, - ]; - } - const contentWithResourceLinks = appendResourceLinks( - fields.content, - msg.resource_links, - ); - if (contentWithResourceLinks !== undefined) { - fields.content = contentWithResourceLinks; - } - if (Object.keys(fields).length > 0) { - emitToolCallUpdate(session, toolUseId, fields, "task_notification"); - } - if (taskId && !deferCompletion) { - unlinkTaskToolUse(session, taskId); - } + if (!fields) return true; + emitToolCallUpdate(session, toolUseId, fields, subtype as "task_started" | "task_progress" | "task_updated" | "task_notification"); + if (taskId && isTerminalToolStatus(fields.status)) unlinkTaskToolUse(session, taskId); return true; } diff --git a/agent-sdk/src/bridge/session_lifecycle.ts b/agent-sdk/src/bridge/session_lifecycle.ts index 123c8201..e6f2515c 100644 --- a/agent-sdk/src/bridge/session_lifecycle.ts +++ b/agent-sdk/src/bridge/session_lifecycle.ts @@ -1,3 +1,4 @@ +import { restoreResumedToolState } from "./history.js"; import { randomUUID } from "node:crypto"; import { resetMainAgentActivity, type MainAgentResponse } from "./activity.js"; import { spawn as spawnChild } from "node:child_process"; @@ -739,6 +740,7 @@ export async function createSession(params: { ? { sessionsToCloseAfterConnect: params.sessionsToCloseAfterConnect } : {}), }; + if (params.resumeUpdates) restoreResumedToolState(session, params.resumeUpdates); refreshCurrentModel(session); const { refreshSupportedModesForSession } = await import("./commands.js"); refreshSupportedModesForSession(session); diff --git a/agent-sdk/src/bridge/tasks.ts b/agent-sdk/src/bridge/tasks.ts index 9c415152..6b0f7854 100644 --- a/agent-sdk/src/bridge/tasks.ts +++ b/agent-sdk/src/bridge/tasks.ts @@ -465,7 +465,7 @@ function taskOutputResultText( return lines.join("\n") || rawText.trim() || extractText(rawResult).trim(); } -function upsertTask(session: SessionState, patch: TaskPatch): TaskItem { +export function upsertTask(session: Pick, patch: TaskPatch): TaskItem { const existing = session.tasksById.get(patch.task_id); const task: TaskItem = { task_id: patch.task_id, @@ -1101,13 +1101,24 @@ export function applyTaskLifecycleState( if (!taskId) { return; } - const patch = asRecordOrNull(msg.patch); const explicitToolUseId = nonEmptyString(msg.tool_use_id); if (explicitToolUseId) { linkTaskToolUse(session, taskId, explicitToolUseId); } - const existing = session.tasksById.get(taskId); + const task = taskLifecyclePatch(session.tasksById.get(taskId), subtype, msg, session.taskToolUseIds.get(taskId)); + if (task) emitTaskStateUpdate(session, "task_lifecycle", [upsertTask(session, task)]); +} + +export function taskLifecyclePatch( + existing: TaskItem | undefined, + subtype: string, + msg: Record, + sourceToolCallId?: string, +): TaskPatch | undefined { + const taskId = nonEmptyString(msg.task_id); + if (!taskId) return undefined; + const patch = asRecordOrNull(msg.patch); const status = lifecycleTaskStatus(subtype, msg); const description = nonEmptyString(patch?.description) ?? @@ -1123,7 +1134,6 @@ export function applyTaskLifecycleState( description ?? taskId; const metadata = mergeMetadata(existing?.metadata, lifecycleMetadata(msg)); - const sourceToolCallId = session.taskToolUseIds.get(taskId); if ( !status && @@ -1135,8 +1145,7 @@ export function applyTaskLifecycleState( return; } - emitTaskStateUpdate(session, "task_lifecycle", [ - upsertTask(session, { + return { task_id: taskId, subject, description, @@ -1144,8 +1153,7 @@ export function applyTaskLifecycleState( status, metadata, source_tool_call_id: sourceToolCallId, - }), - ]); + }; } export function applyBackgroundTasksChanged( diff --git a/agent-sdk/src/bridge/tool_calls.ts b/agent-sdk/src/bridge/tool_calls.ts index e7183629..35c12e5b 100644 --- a/agent-sdk/src/bridge/tool_calls.ts +++ b/agent-sdk/src/bridge/tool_calls.ts @@ -4,7 +4,7 @@ import { emitSessionUpdate } from "./events.js"; import { bridgeLogger, LOG_TARGETS } from "./logger.js"; import type { SessionState } from "./session_lifecycle.js"; import { asRecordOrNull } from "./shared.js"; -import { applyTaskToolResult } from "./tasks.js"; +import { applyTaskLifecycleState, applyTaskToolResult } from "./tasks.js"; import { activeTaskIdForToolUse, linkTaskToolUse, @@ -15,6 +15,7 @@ import { backgroundToolLaunchTaskIdFromResult, buildToolResultFields, createToolCall, + isShellToolName, } from "./tooling.js"; import { appendResourceLinks } from "./resource_links.js"; @@ -63,7 +64,7 @@ function jsonSize(value: unknown): number | undefined { } } -function toolNameFromMeta( +export function toolNameFromMeta( meta: ToolCall["meta"] | undefined, ): string | undefined { if (!meta || typeof meta !== "object") { @@ -105,25 +106,15 @@ export function toolUsesSummaryOutput(base: ToolCall | undefined): boolean { export function toolAcceptsTaskLifecycle(base: ToolCall | undefined): boolean { const baseToolName = toolName(base); - return Boolean(baseToolName && TASK_LIFECYCLE_TOOL_NAMES.has(baseToolName)); -} - -export function toolAcceptsTerminalTaskNotification( - base: ToolCall | undefined, -): boolean { - const baseToolName = toolName(base); - return Boolean( - baseToolName && - (TASK_LIFECYCLE_TOOL_NAMES.has(baseToolName) || - baseToolName === "Bash" || - baseToolName.startsWith("mcp__")), - ); + return Boolean(baseToolName && ( + TASK_LIFECYCLE_TOOL_NAMES.has(baseToolName) || isShellToolName(baseToolName) || baseToolName.startsWith("mcp__") + )); } export function toolPreservesTaskNotificationOutput( base: ToolCall | undefined, ): boolean { - return toolName(base) === "Bash"; + return isShellToolName(toolName(base) ?? ""); } export function defersTaskNotificationCompletion( @@ -244,7 +235,7 @@ function mergeTaskMetadata( return merged; } -function applyFieldsToBase(base: ToolCall, fields: ToolCallUpdateFields): void { +export function applyFieldsToBase(base: ToolCall, fields: ToolCallUpdateFields): void { if (fields.title !== undefined) { base.title = fields.title; } @@ -401,14 +392,19 @@ function taskTitleContext( }; } -export function emitToolCallUpdate( - session: SessionState, - toolUseId: string, +export function normalizeToolCallUpdateFields( + base: ToolCall | undefined, fields: ToolCallUpdateFields, updateKind: ToolUpdateKind, - sourceMessageUuid?: string, -): void { - const base = session.toolCalls.get(toolUseId); +): boolean { + const baseTerminal = base?.status === "completed" || base?.status === "failed" || base?.status === "killed"; + if (baseTerminal && (fields.status === "pending" || fields.status === "in_progress" || fields.status === "detached" || updateKind.startsWith("task_"))) return false; + // All SDK launch and lifecycle adapters converge here. A running task keeps + // its independent execution state across acknowledgements and progress. + if ((fields.status === "pending" || fields.status === "in_progress" || fields.status === undefined) && + (fields.task_metadata?.is_backgrounded === true || base?.status === "detached")) { + fields.status = "detached"; + } const nextStatus = fields.status ?? base?.status; const terminal = nextStatus === "completed" || @@ -423,6 +419,18 @@ export function emitToolCallUpdate( subagent_retry: { state: "clear" }, }; } + return true; +} + +export function emitToolCallUpdate( + session: SessionState, + toolUseId: string, + fields: ToolCallUpdateFields, + updateKind: ToolUpdateKind, + sourceMessageUuid?: string, +): void { + const base = session.toolCalls.get(toolUseId); + if (!normalizeToolCallUpdateFields(base, fields, updateKind)) return; logToolCallUpdateEmitted( session.sessionId, toolUseId, @@ -563,6 +571,7 @@ export function emitToolResultUpdate( fields.content = contentWithResourceLinks; } applyToolNonExecutionMetadata(fields, nonExecutionMetadata); + if (!normalizeToolCallUpdateFields(base, fields, "result")) return; if (!isError && !nonExecutionMetadata) { const taskId = backgroundToolLaunchTaskIdFromResult( baseToolName, @@ -571,6 +580,10 @@ export function emitToolResultUpdate( ); if (taskId) { linkTaskToolUse(session, taskId, toolUseId); + applyTaskLifecycleState(session, "task_started", { + task_id: taskId, tool_use_id: toolUseId, + description: base?.title, is_backgrounded: true, + }); } } emitToolCallUpdate(session, toolUseId, fields, "result", sourceMessageUuid); @@ -585,7 +598,8 @@ export function emitToolResultUpdate( rawContent, rawResult, ); - if (baseToolName === "Agent" || baseToolName === "Task") { + if ((baseToolName === "Agent" || baseToolName === "Task") && + (base?.status === "completed" || base?.status === "failed" || base?.status === "killed")) { const taskId = activeTaskIdForToolUse(session, toolUseId); if (taskId) { unlinkTaskToolUse(session, taskId); @@ -593,6 +607,16 @@ export function emitToolResultUpdate( } } +function toolBelongsToBackgroundExecution(session: SessionState, toolUseId: string): boolean { + let current: string | null = toolUseId; + for (let depth = 0; current && depth <= session.toolCalls.size; depth++) { + const tool = session.toolCalls.get(current); + if (tool?.status === "detached") return true; + current = parentToolUseIdFromMeta(tool?.meta); + } + return false; +} + export function finalizeOpenToolCalls( session: SessionState, status: "completed" | "failed", @@ -602,8 +626,8 @@ export function finalizeOpenToolCalls( continue; } if ( - toolAcceptsTerminalTaskNotification(toolCall) && - activeTaskIdForToolUse(session, toolUseId) + toolBelongsToBackgroundExecution(session, toolUseId) || + (toolAcceptsTaskLifecycle(toolCall) && activeTaskIdForToolUse(session, toolUseId)) ) { continue; } @@ -722,7 +746,7 @@ export function resolveTaskToolUseId( if (!taskId) { return ""; } - return session.taskToolUseIds.get(taskId) ?? ""; + return session.taskToolUseIds.get(taskId) ?? session.tasksById.get(taskId)?.source_tool_call_id ?? ""; } export function taskProgressText(msg: Record): string { diff --git a/agent-sdk/src/bridge/tooling.test.ts b/agent-sdk/src/bridge/tooling.test.ts index a7b80981..cf66fb05 100644 --- a/agent-sdk/src/bridge/tooling.test.ts +++ b/agent-sdk/src/bridge/tooling.test.ts @@ -2498,7 +2498,7 @@ test("buildToolResultFields renders Monitor launch output as structured text", ( base, ); - assert.equal(fields.status, "in_progress"); + assert.equal(fields.status, "detached"); assert.equal( fields.raw_output, "Task ID: monitor-1\nPersistent: no\nTimeout: 30s", @@ -2536,7 +2536,7 @@ test("buildToolResultFields renders Workflow launch output as structured text", base, ); - assert.equal(fields.status, "in_progress"); + assert.equal(fields.status, "detached"); assert.equal( fields.raw_output, "Status: async launched\nTask ID: workflow-1\nTask type: local_workflow\nWorkflow name: spec\nRun ID: run-1\nSummary: Workflow started\nTranscript dir: C:/tmp/transcripts\nScript path: C:/tmp/workflow.js\nWarning: branch diverged", diff --git a/agent-sdk/src/bridge/tooling.ts b/agent-sdk/src/bridge/tooling.ts index ee0287e8..0f2366d0 100644 --- a/agent-sdk/src/bridge/tooling.ts +++ b/agent-sdk/src/bridge/tooling.ts @@ -2677,6 +2677,15 @@ function backgroundLaunchResultFields( rawResult: unknown, rawContent: unknown, ): BackgroundLaunchResult | undefined { + if (isAgentLikeToolName(toolName)) { + const record = resultRecordCandidates(rawResult, rawContent).find( + candidate => candidate.status === "async_launched", + ); + const taskId = nonEmptyString(record?.agentId); + if (record && taskId) { + return { taskId, keepRunning: true, failed: false, output: nonEmptyString(record.description) }; + } + } if (isShellToolName(toolName)) { const record = findShellResultRecord(rawResult, rawContent); const taskId = @@ -3067,7 +3076,7 @@ export function buildToolResultFields( fields.status = backgroundLaunchOutput.failed ? "failed" : backgroundLaunchOutput.keepRunning - ? "in_progress" + ? "detached" : "completed"; if (backgroundLaunchOutput.output) { fields.raw_output = backgroundLaunchOutput.output; diff --git a/agent-sdk/src/types.ts b/agent-sdk/src/types.ts index 0e70179e..bcfeece1 100644 --- a/agent-sdk/src/types.ts +++ b/agent-sdk/src/types.ts @@ -392,6 +392,7 @@ export type SessionUpdate = source_message_uuid?: string; origin: MessageOrigin; } + | { type: "agent_response_started" } | { type: "agent_activity_update"; phase: "working" | "thinking"; diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 7d1daf60..ba212050 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -102,6 +102,14 @@ Rust consumes the typed phases without interpreting SDK content and never infers A turn keeps one verb and one shuffled traversal of the shared welcome tips, advanced by monotonic deadlines on the TUI wake loop, including under reduced motion. `spinnerTipsEnabled` from the acknowledged snapshot controls only the tip text; activity, its heading and SDK thinking are independent of it. +### Background tool execution + +The bridge normalizes confirmed launch results and SDK task lifecycle events into the existing `Detached` tool status. Requested background flags and host elapsed time do not confirm a handoff. Task-to-tool correlation survives foreground turn completion and resume; stale progress and repeated launch acknowledgements cannot reopen terminal execution. Live and replay use the same task adapters and transition rules. + +Rust keeps execution state on the original `ToolCallInfo`. Its history projection freezes the launch at its creation position with the fixed `↗` icon, allowing the transcript prefix to enter terminal history while the task runs independently. Actual completion, failure or stopping appends one separately identified result linked to that tool. The session owns pending interactions; foreground turn reset preserves background controls, which remain mutable at the end of the live region during transcript replay after resize. Cache and retention accounting include the frozen projections without adding them to the execution index or foreground activity set. + +The bridge announces each live top-level SDK response through the typed `agent_response_started` update before sending its text. A response triggered by a background completion receives a fresh mutable assistant owner even when no user prompt is pending; history replay does not activate live responses. The renderer groups consecutive visible assistant messages under one speaker label, carrying speaker context across content already inserted into terminal history. Streaming ownership, message identities and tool insertion order remain separate from that visual grouping. + ### Notifications | Concern | Owner | Location | diff --git a/src/agent/model/session.rs b/src/agent/model/session.rs index dc60f01a..2f47ef3a 100644 --- a/src/agent/model/session.rs +++ b/src/agent/model/session.rs @@ -279,6 +279,7 @@ pub enum SessionUpdate { AgentMessageChunk(ContentChunk), UserMessageChunk(ContentChunk), ExternalMessageUpdate(ExternalMessageUpdate), + AgentResponseStarted, AgentActivityUpdate(AgentActivityPhase), ToolCall(ToolCall), ToolCallUpdate(ToolCallUpdate), diff --git a/src/agent/types.rs b/src/agent/types.rs index 69b3e395..5ed03391 100644 --- a/src/agent/types.rs +++ b/src/agent/types.rs @@ -652,6 +652,7 @@ pub enum SessionUpdate { source_message_uuid: Option, origin: MessageOrigin, }, + AgentResponseStarted, AgentActivityUpdate { phase: super::model::AgentActivityPhase, }, diff --git a/src/agent/wire.rs b/src/agent/wire.rs index 6d464fd1..a0d369db 100644 --- a/src/agent/wire.rs +++ b/src/agent/wire.rs @@ -1670,6 +1670,20 @@ mod tests { ); } + #[test] + fn live_response_start_roundtrips_without_fabricating_a_user_message() { + let raw = serde_json::json!({ + "event": "session_update", "session_id": "background-session", + "update": {"type": "agent_response_started"} + }); + let decoded: BridgeEvent = + serde_json::from_value(raw.clone()).expect("live response start"); + assert!(matches!(decoded, BridgeEvent::SessionUpdate { + ref session_id, update: types::SessionUpdate::AgentResponseStarted, + } if session_id == "background-session")); + assert_eq!(serde_json::to_value(decoded).expect("encode response start"), raw); + } + #[test] fn observed_activity_phase_roundtrips_without_thinking_content() { for (name, expected) in [ diff --git a/src/app/activity.rs b/src/app/activity.rs index 67599e52..7a897189 100644 --- a/src/app/activity.rs +++ b/src/app/activity.rs @@ -91,7 +91,7 @@ impl App { self.session_runtime.runtime_session_state, Some(RuntimeSessionState::Idle | RuntimeSessionState::RequiresAction) ) - || !self.turn.pending_interaction_ids.is_empty() + || !self.pending_interaction_ids.is_empty() || self.mcp.pending_elicitation.is_some() { return None; @@ -246,7 +246,7 @@ mod tests { assert_eq!(working.verb, verb); assert_eq!(working.tip, second); - app.turn.pending_interaction_ids.push("tool".into()); + app.pending_interaction_ids.push("tool".into()); assert!( app.activity_presentation(start).is_none(), "locally delivered interaction hides immediately" @@ -255,7 +255,7 @@ mod tests { &mut app, model::SessionUpdate::RuntimeSessionStateUpdate(RuntimeSessionState::RequiresAction), ); - app.turn.pending_interaction_ids.clear(); + app.pending_interaction_ids.clear(); assert!(app.activity_presentation(start).is_none(), "wait for SDK work to resume"); update( &mut app, @@ -286,10 +286,9 @@ mod tests { model::ContentBlock::Text(model::TextContent::new("Late output")), )), ); - update( - &mut app, - model::SessionUpdate::AgentActivityUpdate(model::AgentActivityPhase::Thinking), - ); + for phase in [model::AgentActivityPhase::Thinking, model::AgentActivityPhase::Working] { + update(&mut app, model::SessionUpdate::AgentActivityUpdate(phase)); + } update( &mut app, model::SessionUpdate::ToolCall( diff --git a/src/app/connect/mod.rs b/src/app/connect/mod.rs index 0295e0c0..e3979960 100644 --- a/src/app/connect/mod.rs +++ b/src/app/connect/mod.rs @@ -146,6 +146,7 @@ pub fn create_app(cli: &Cli) -> App { spinner_frame: 0, spinner_last_advance_at: None, tool_call_scopes: HashMap::new(), + pending_interaction_ids: Vec::new(), focus: FocusManager::default(), keymap: super::keymap::ResolvedKeymap::defaults(), plugins: PluginsState::default(), diff --git a/src/app/connect/type_converters.rs b/src/app/connect/type_converters.rs index f26e73e0..39d60082 100644 --- a/src/app/connect/type_converters.rs +++ b/src/app/connect/type_converters.rs @@ -426,6 +426,9 @@ pub(super) fn map_session_update(update: types::SessionUpdate) -> Option { + Some(model::SessionUpdate::AgentResponseStarted) + } types::SessionUpdate::AgentActivityUpdate { phase } => { Some(model::SessionUpdate::AgentActivityUpdate(phase)) } diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 6bfaa20b..192f6e7c 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -311,6 +311,10 @@ fn dispatch_paste_by_view(app: &mut App, text: &str) -> bool { } fn handle_session_update_event(app: &mut App, update: model::SessionUpdate) { + // Replay uses handle_session_update directly and must never start live activity. + if matches!(&update, model::SessionUpdate::AgentResponseStarted) { + streaming::begin_live_assistant_response(app); + } let needs_history_retention = matches!( &update, model::SessionUpdate::AgentMessageChunk(_) @@ -363,7 +367,7 @@ fn handle_session_update(app: &mut App, update: model::SessionUpdate) { ); apply_task_state_update(app, update); } - model::SessionUpdate::UserMessageChunk(_) => {} + model::SessionUpdate::UserMessageChunk(_) | model::SessionUpdate::AgentResponseStarted => {} model::SessionUpdate::MessageMetadata { role, timestamp, source_message_uuid } => { crate::app::presentation::apply_message_metadata( app, diff --git a/src/app/events/notifications.rs b/src/app/events/notifications.rs index c17bcb32..c24fbd7e 100644 --- a/src/app/events/notifications.rs +++ b/src/app/events/notifications.rs @@ -268,7 +268,7 @@ mod tests { interaction_id: id.into(), }, ); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } }); let decisions: Vec<_> = logs @@ -329,7 +329,7 @@ mod tests { interaction_id: "permission-1".into(), }, ); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); set(&mut app, "notifications.actionsRequired", json!(false)); let (tx, _rx) = tokio::sync::oneshot::channel(); crate::app::events::handle_client_event( diff --git a/src/app/events/retraction.rs b/src/app/events/retraction.rs index 7bb3dd64..c0c89158 100644 --- a/src/app/events/retraction.rs +++ b/src/app/events/retraction.rs @@ -83,7 +83,7 @@ fn block_matches_retraction(block: &MessageBlock, message_uuids: &HashSet<&str>) MessageBlock::Text(block) => { block.source_message_uuids.iter().any(|uuid| message_uuids.contains(uuid.as_str())) } - MessageBlock::ToolCall(tool_call) => { + MessageBlock::ToolCall(tool_call) | MessageBlock::ToolResult { tool: tool_call, .. } => { tool_call.source_message_uuids.iter().any(|uuid| message_uuids.contains(uuid.as_str())) } MessageBlock::BtwExchange(_) diff --git a/src/app/events/session_reset.rs b/src/app/events/session_reset.rs index 9f17c83f..85be59d6 100644 --- a/src/app/events/session_reset.rs +++ b/src/app/events/session_reset.rs @@ -60,6 +60,7 @@ pub(super) fn reset_for_conversation( app.session_runtime.last_rate_limit_update = None; app.files_accessed = 0; app.turn.reset_for_new_session(); + app.pending_interaction_ids.clear(); app.clear_tool_scope_tracking(); app.clear_tool_call_index(); app.sdk_inventory.tasks.clear(); @@ -154,6 +155,7 @@ fn reset_input_state_for_new_session(app: &mut App, reset_kind: ChatResetKind) { fn reset_interaction_state_for_new_session(app: &mut App) { app.turn.reset_for_new_session(); + app.pending_interaction_ids.clear(); app.clear_tool_scope_tracking(); app.clear_tool_call_index(); app.sdk_inventory.tasks.clear(); @@ -257,7 +259,7 @@ pub(super) fn load_resume_history(app: &mut App, history_updates: &[model::Sessi for message in &mut app.transcript.messages { message.timing.discard_replay_observations(); } - app.finalize_session_runtime_artifacts(model::ToolCallStatus::Failed); + app.finalize_turn_runtime_artifacts(model::ToolCallStatus::Failed); app.clear_active_turn_assistant(); super::compaction::reset(app); app.status = super::super::AppStatus::Ready; diff --git a/src/app/events/streaming.rs b/src/app/events/streaming.rs index 1ecb36bf..91d654c1 100644 --- a/src/app/events/streaming.rs +++ b/src/app/events/streaming.rs @@ -7,6 +7,18 @@ use super::super::{ }; use crate::agent::model; +/// A live SDK response can start after a background notification without a user prompt. +/// Give it a fresh mutable owner instead of extending already committed text. +pub(super) fn begin_live_assistant_response(app: &mut App) { + if !matches!(app.status, super::AppStatus::Ready | super::AppStatus::Error) { + return; + } + app.push_message_tracked(ChatMessage::new(MessageRole::Assistant, Vec::new(), None)); + app.bind_active_turn_assistant_to_tail(); + app.status = super::AppStatus::Running; + app.begin_turn_activity(std::time::Instant::now()); +} + pub(super) fn handle_agent_message_chunk(app: &mut App, chunk: model::ContentChunk) { let model::ContentBlock::Text(text) = chunk.content else { return; diff --git a/src/app/events/tests/client_events.rs b/src/app/events/tests/client_events.rs index 13251072..0f5110b8 100644 --- a/src/app/events/tests/client_events.rs +++ b/src/app/events/tests/client_events.rs @@ -467,7 +467,7 @@ fn session_replaced_resets_chat_and_transient_state() { .push(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete("world"))])); app.status = AppStatus::Running; app.files_accessed = 9; - app.turn.pending_interaction_ids.push("perm-1".into()); + app.pending_interaction_ids.push("perm-1".into()); app.sdk_inventory.tasks.push(task_item("task-1", "Task", model::TaskStatus::InProgress)); app.mention = Some(mention::MentionState::new(0, 0, String::new(), Vec::new())); app.mcp.servers.push(crate::agent::model::McpServerStatus { @@ -513,7 +513,7 @@ fn session_replaced_resets_chat_and_transient_state() { assert_eq!(app.transcript.messages.len(), 1); assert!(matches!(app.transcript.messages[0].role, MessageRole::Welcome)); assert_eq!(app.files_accessed, 0); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); assert!(app.sdk_inventory.tasks.is_empty()); assert!(app.mention.is_none()); assert!(app.mcp.servers.is_empty()); diff --git a/src/app/events/tests/mod.rs b/src/app/events/tests/mod.rs index 308daa5f..121261be 100644 --- a/src/app/events/tests/mod.rs +++ b/src/app/events/tests/mod.rs @@ -34,6 +34,68 @@ fn turn_complete(terminal_reason: Option) - } } +#[test] +fn autonomous_response_owns_fresh_text_and_preserves_an_existing_live_owner() { + let mut app = make_test_app(); + app.status = AppStatus::Ready; + app.push_message_tracked(assistant_msg(vec![MessageBlock::Text(TextBlock::from_complete( + "Already completed and inserted.", + ))])); + let finished_id = app.transcript.messages[0].id; + handle_client_event(&mut app, session_update(model::SessionUpdate::AgentResponseStarted)); + assert_eq!(app.status, AppStatus::Running); + assert_eq!(app.active_turn_assistant_idx(), Some(1)); + let active_id = app.transcript.messages[1].id; + assert!(app.turn.activity.is_some()); + for text in ["The", " background task finished completely."] { + handle_client_event( + &mut app, + session_update(model::SessionUpdate::AgentMessageChunk(model::ContentChunk::new( + model::ContentBlock::Text(model::TextContent::new(text)), + ))), + ); + // Further main response starts inside a live turn must keep its owner. + handle_client_event(&mut app, session_update(model::SessionUpdate::AgentResponseStarted)); + assert_eq!(app.transcript.messages.len(), 2); + assert_eq!(app.transcript.messages[1].id, active_id); + } + assert_eq!(app.transcript.messages[0].id, finished_id); + let MessageBlock::Text(finished) = &app.transcript.messages[0].blocks[0] else { + panic!("finished text"); + }; + assert_eq!(finished.text, "Already completed and inserted."); + let MessageBlock::Text(reply) = &app.transcript.messages[1].blocks[0] else { + panic!("completion reply"); + }; + assert_eq!(reply.text, "The background task finished completely."); + handle_client_event(&mut app, turn_complete(None)); + assert_eq!(app.status, AppStatus::Ready); + assert_eq!(app.active_turn_assistant_idx(), None); + for phase in [model::AgentActivityPhase::Working, model::AgentActivityPhase::Thinking] { + handle_client_event( + &mut app, + session_update(model::SessionUpdate::AgentActivityUpdate(phase)), + ); + } + assert_eq!(app.status, AppStatus::Ready); + assert_eq!(app.active_turn_assistant_idx(), None); + assert!(app.turn.activity.is_none()); + handle_client_event( + &mut app, + ClientEvent::TurnError { + session_id: "test-session".to_owned(), + message: "Fixture service unavailable".to_owned(), + queued_turn_count: None, + api_error_status: Some(503), + terminal_reason: None, + }, + ); + assert_eq!(app.status, AppStatus::Error); + handle_client_event(&mut app, session_update(model::SessionUpdate::AgentResponseStarted)); + assert_eq!(app.status, AppStatus::Running); + assert_eq!(app.active_turn_assistant_idx(), Some(app.transcript.messages.len() - 1)); +} + fn slash_command_error(message: String) -> ClientEvent { ClientEvent::SlashCommandError { session_id: None, message } } @@ -182,12 +244,14 @@ fn block_snapshot(block: &MessageBlock) -> BlockSnapshot { MessageBlock::Notice(block) => { BlockSnapshot::Notice { severity: block.severity, text: block.text.text.clone() } } - MessageBlock::ToolCall(tool_call) => BlockSnapshot::ToolCall { - id: tool_call.id.clone(), - title: tool_call.title.clone(), - status: tool_call.status, - hidden: tool_call.hidden, - }, + MessageBlock::ToolCall(tool_call) | MessageBlock::ToolResult { tool: tool_call, .. } => { + BlockSnapshot::ToolCall { + id: tool_call.id.clone(), + title: tool_call.title.clone(), + status: tool_call.status, + hidden: tool_call.hidden, + } + } MessageBlock::Welcome(block) => BlockSnapshot::Welcome { version: block.version.clone(), subscription: block.subscription.clone(), @@ -343,7 +407,9 @@ fn first_block_text(msg: &ChatMessage) -> &str { Some(MessageBlock::BtwExchange(_)) => { panic!("expected text-like block, found BTW exchange") } - Some(MessageBlock::ToolCall(_)) => panic!("expected text-like block, found tool call"), + Some(MessageBlock::ToolCall(_) | MessageBlock::ToolResult { .. }) => { + panic!("expected text-like block, found tool call") + } Some(MessageBlock::Welcome(_)) => panic!("expected text-like block, found welcome"), Some(MessageBlock::ImageAttachment(_)) => { panic!("expected text-like block, found image attachment") @@ -726,6 +792,7 @@ fn canonical_messages_contain_text(app: &App, expected: &str) -> bool { exchange.question == expected || exchange.answer == expected } MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } | MessageBlock::Welcome(_) | MessageBlock::ImageAttachment(_) | MessageBlock::UserDialog(_) => false, @@ -952,7 +1019,7 @@ fn test_app_defaults() { assert!(!app.shutdown_requested()); assert!(app.session_runtime.session_id.is_none()); assert_eq!(app.files_accessed, 0); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); assert_eq!(app.surface_dirty.chat.rebuild, ChatRebuildKind::None); assert!(app.sdk_inventory.tasks.is_empty()); assert!(app.mention.is_none()); @@ -1381,7 +1448,7 @@ fn resume_history_clears_active_turn_owner_after_loading() { } #[test] -fn resume_history_clears_tool_scope_tracking_after_loading() { +fn resume_history_clears_foreground_tracking_and_retains_tool_relationships() { let mut app = make_test_app(); let task_tool = model::ToolCall::new("resume-task", "Run subagent") .kind(model::ToolKind::Think) @@ -1405,7 +1472,12 @@ fn resume_history_clears_tool_scope_tracking_after_loading() { ); assert!(app.turn.active_task_ids.is_empty()); - assert_eq!(app.tool_call_scope("resume-task"), None); + assert_eq!(app.tool_call_scope("resume-task"), Some(ToolCallScope::SubagentRoot)); + let (mi, bi) = app.lookup_tool_call("resume-task").expect("resumed foreground tool"); + let MessageBlock::ToolCall(tool) = &app.transcript.messages[mi].blocks[bi] else { + panic!("resumed tool call"); + }; + assert_eq!(tool.status, model::ToolCallStatus::Failed); } #[test] @@ -1774,14 +1846,16 @@ fn classified_turn_error_transient_service_suggests_retry() { } #[test] -fn turn_error_clears_tool_scope_tracking() { +fn turn_error_clears_foreground_tracking_without_misclassifying_the_next_tool() { let mut app = make_test_app(); - app.transcript.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tool_call( - "task-1", - model::ToolCallStatus::InProgress, - )))])); - app.register_tool_call_scope("task-1".into(), ToolCallScope::SubagentRoot); - app.insert_active_task("task-1".into()); + handle_client_event( + &mut app, + session_update(model::SessionUpdate::ToolCall( + model::ToolCall::new("task-1", "Run subagent") + .status(model::ToolCallStatus::InProgress) + .meta(serde_json::json!({"claudeCode": {"toolName": "Agent"}})), + )), + ); handle_client_event( &mut app, @@ -1795,7 +1869,22 @@ fn turn_error_clears_tool_scope_tracking() { ); assert!(app.turn.active_task_ids.is_empty()); - assert_eq!(app.tool_call_scope("task-1"), None); + assert_eq!(app.tool_call_scope("task-1"), Some(ToolCallScope::SubagentRoot)); + let (mi, bi) = app.lookup_tool_call("task-1").expect("failed foreground tool"); + let MessageBlock::ToolCall(tool) = &app.transcript.messages[mi].blocks[bi] else { + panic!("foreground tool call"); + }; + assert_eq!(tool.status, model::ToolCallStatus::Failed); + handle_client_event( + &mut app, + session_update(model::SessionUpdate::ToolCall( + model::ToolCall::new("next-bash", "Foreground command") + .status(model::ToolCallStatus::InProgress) + .meta(serde_json::json!({"claudeCode": {"toolName": "Bash"}})), + )), + ); + assert_eq!(app.tool_call_scope("next-bash"), Some(ToolCallScope::MainAgent)); + assert!(app.turn.active_task_ids.is_empty()); } #[test] @@ -1817,7 +1906,7 @@ fn auth_required_clears_active_turn_runtime_tracking() { app.bind_active_turn_assistant(0); app.register_tool_call_scope("task-1".into(), ToolCallScope::SubagentRoot); app.insert_active_task("task-1".into()); - app.turn.pending_interaction_ids.push("task-1".into()); + app.pending_interaction_ids.push("task-1".into()); app.claim_focus_target(FocusTarget::Permission); handle_client_event( @@ -1830,7 +1919,7 @@ fn auth_required_clears_active_turn_runtime_tracking() { assert_eq!(app.active_turn_assistant_idx(), None); assert!(app.turn.active_task_ids.is_empty()); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); assert_ne!(app.focus_owner(), FocusOwner::Permission); let Some(MessageBlock::ToolCall(tc)) = app.transcript.messages[0].blocks.first() else { panic!("expected tool call block"); @@ -2616,13 +2705,13 @@ fn permission_owner_handles_up_down_for_pending_interactions() { handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-b", "perm-a"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-b", "perm-a"]); } #[test] fn permission_focus_allows_typing_for_non_permission_keys() { let mut app = make_test_app(); - app.turn.pending_interaction_ids.push("perm-1".into()); + app.pending_interaction_ids.push("perm-1".into()); app.claim_focus_target(FocusTarget::Permission); handle_terminal_event( @@ -2665,7 +2754,7 @@ fn permission_request_with_existing_draft_does_not_claim_focus() { ); assert_eq!(app.focus_owner(), FocusOwner::Input); - assert_eq!(app.turn.pending_interaction_ids, vec![tool_id]); + assert_eq!(app.pending_interaction_ids, vec![tool_id]); assert_eq!(permission_focus_state(&app, tool_id), Some(false)); } @@ -2698,7 +2787,7 @@ fn question_request_with_existing_draft_does_not_claim_focus() { ); assert_eq!(app.focus_owner(), FocusOwner::Input); - assert_eq!(app.turn.pending_interaction_ids, vec![tool_id]); + assert_eq!(app.pending_interaction_ids, vec![tool_id]); assert_eq!(question_focus_state(&app, tool_id), Some(false)); } @@ -2744,7 +2833,7 @@ fn enter_submits_draft_when_permission_arrives_mid_compose() { super::super::finalize_deferred_submit(&mut app); assert!(app.pending_submit.is_none()); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); assert!(bridge_rx.try_recv().is_ok()); assert!(response_rx.try_recv().is_err()); } @@ -2900,13 +2989,13 @@ fn stale_inline_interaction_queue_head_is_pruned_before_enter_response() { ], false, ); - app.turn.pending_interaction_ids.insert(0, "stale-id".into()); + app.pending_interaction_ids.insert(0, "stale-id".into()); handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))); let response = response_rx.try_recv().expect("permission response"); assert!(matches!(response.outcome, model::RequestPermissionOutcome::Selected(_))); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } #[test] @@ -3018,7 +3107,7 @@ fn attach_pending_permission( app.transcript.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tc))])); let msg_idx = app.transcript.messages.len().saturating_sub(1); app.index_tool_call(tool_id.into(), msg_idx, 0); - app.turn.pending_interaction_ids.push(tool_id.into()); + app.pending_interaction_ids.push(tool_id.into()); app.claim_focus_target(FocusTarget::Permission); response_rx } @@ -3048,7 +3137,7 @@ fn attach_pending_question( app.transcript.messages.push(assistant_msg(vec![MessageBlock::ToolCall(Box::new(tc))])); let msg_idx = app.transcript.messages.len().saturating_sub(1); app.index_tool_call(tool_id.into(), msg_idx, 0); - app.turn.pending_interaction_ids.push(tool_id.into()); + app.pending_interaction_ids.push(tool_id.into()); if focused { app.claim_focus_target(FocusTarget::Permission); } @@ -3093,7 +3182,7 @@ fn permission_ctrl_y_does_not_resolve_pending_permission() { response_rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty) )); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -3127,7 +3216,7 @@ fn permission_ctrl_a_does_not_resolve_pending_permission() { response_rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty) )); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -3168,7 +3257,7 @@ fn permission_ctrl_n_does_not_bypass_mention_focus() { response_rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty) )); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -3203,7 +3292,7 @@ fn plan_approval_raw_ctrl_y_does_not_resolve_permission() { Err(tokio::sync::oneshot::error::TryRecvError::Empty) )); assert_eq!(app.input.text(), "seed"); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -3228,7 +3317,7 @@ fn second_esc_after_permission_rejection_requests_turn_cancel() { panic!("expected selected permission response"); }; assert_eq!(selected.option_id.clone(), "deny"); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); assert!(!app.turn.cancel_requested); handle_terminal_event(&mut app, Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); diff --git a/src/app/events/tool_calls.rs b/src/app/events/tool_calls.rs index 9042cbed..9fec2738 100644 --- a/src/app/events/tool_calls.rs +++ b/src/app/events/tool_calls.rs @@ -20,6 +20,7 @@ pub(super) fn handle_tool_call(app: &mut App, tc: model::ToolCall) { log_command_started(app, &tool_info); log_terminal_spawned(app, &tool_info, "initial"); upsert_tool_call_into_assistant_message(app, tool_info); + app.sync_tool_call_history(&id_str); crate::app::tasks::refresh_task_tool_displays(app); app.files_accessed += 1; @@ -80,18 +81,7 @@ pub(super) fn update_subagent_scope_state( ) { match scope { ToolCallScope::SubagentChild { .. } | ToolCallScope::MainAgent => {} - ToolCallScope::SubagentRoot => match status { - model::ToolCallStatus::InProgress - | model::ToolCallStatus::Pending - | model::ToolCallStatus::Detached => { - app.insert_active_task(id.to_owned()); - } - model::ToolCallStatus::Completed - | model::ToolCallStatus::Failed - | model::ToolCallStatus::Killed => { - app.remove_active_task(id); - } - }, + ToolCallScope::SubagentRoot => app.sync_active_task_status(id, status), } } @@ -136,6 +126,7 @@ fn build_tool_info_from_tool_call( cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::default(), }; tool_info.raw_input_bytes = tool_info.raw_input.as_ref().map_or(0, ToolCallInfo::estimate_json_value_bytes); diff --git a/src/app/events/tool_updates.rs b/src/app/events/tool_updates.rs index f7905f17..1bd51aab 100644 --- a/src/app/events/tool_updates.rs +++ b/src/app/events/tool_updates.rs @@ -45,6 +45,7 @@ pub(super) fn handle_tool_call_update_session(app: &mut App, tcu: &model::ToolCa apply_tool_scope_status_update(app, &id_str, tool_scope.as_ref(), tcu.fields.status); let update_outcome = apply_tool_call_update_to_indexed_block(app, mi, bi, tcu); + app.sync_tool_call_history(&id_str); crate::app::tasks::refresh_task_tool_displays(app); if let Some(mi) = update_outcome.layout_dirty_idx { app.recompute_message_retained_bytes(mi); @@ -71,18 +72,7 @@ fn apply_tool_scope_status_update( return; }; match tool_scope { - Some(ToolCallScope::SubagentRoot) => match status { - model::ToolCallStatus::Pending - | model::ToolCallStatus::InProgress - | model::ToolCallStatus::Detached => { - app.insert_active_task(id_str.to_owned()); - } - model::ToolCallStatus::Completed - | model::ToolCallStatus::Failed - | model::ToolCallStatus::Killed => { - app.remove_active_task(id_str); - } - }, + Some(ToolCallScope::SubagentRoot) => app.sync_active_task_status(id_str, status), Some(ToolCallScope::SubagentChild { .. } | ToolCallScope::MainAgent) | None => {} } } @@ -599,7 +589,11 @@ fn log_command_update_applied( let transitioned_to_final = matches!( previous_status, - Some(model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress) + Some( + model::ToolCallStatus::Pending + | model::ToolCallStatus::InProgress + | model::ToolCallStatus::Detached + ) ) && matches!( tc.status, model::ToolCallStatus::Completed @@ -698,6 +692,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -726,6 +721,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -847,7 +843,7 @@ mod tests { } #[test] - fn deferred_web_output_updates_the_original_card_after_turn_completion() { + fn deferred_web_output_preserves_launch_and_appends_linked_result_after_turn_completion() { let mut app = App::test_default(); let mut tool = make_task_tool_call("web-1", model::ToolCallStatus::InProgress); tool.sdk_tool_name = "WebFetch".to_owned(); @@ -883,7 +879,16 @@ mod tests { }; assert_eq!(tool.status, model::ToolCallStatus::Completed); assert_eq!(tool.content, vec![model::ToolCallContent::from("Actual web output")]); - assert_eq!(app.transcript.messages.len(), 1); + assert_eq!(tool.display().status, model::ToolCallStatus::Detached); + assert_eq!(app.transcript.messages.len(), 2); + let MessageBlock::ToolResult { tool: result, .. } = &app.transcript.messages[1].blocks[0] + else { + panic!("expected linked result") + }; + assert_eq!(result.id, "web-1"); + assert_eq!(result.status, model::ToolCallStatus::Completed); + assert_eq!(result.content, vec![model::ToolCallContent::from("Actual web output")]); + assert_eq!(app.tool_call_index_len(), 1); } #[test] diff --git a/src/app/events/turn.rs b/src/app/events/turn.rs index a7258bb2..eb0747d6 100644 --- a/src/app/events/turn.rs +++ b/src/app/events/turn.rs @@ -191,7 +191,7 @@ pub(super) fn handle_permission_request_event( return; }; - if app.turn.pending_interaction_ids.iter().any(|id| id == &tool_id) { + if app.pending_interaction_ids.iter().any(|id| id == &tool_id) { tracing::warn!( target: crate::logging::targets::APP_PERMISSION, event_name = "permission_request_rejected", @@ -206,8 +206,7 @@ pub(super) fn handle_permission_request_event( } let mut layout_dirty = false; - let auto_focus = - app.turn.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); + let auto_focus = app.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); if let Some(MessageBlock::ToolCall(tc)) = app.transcript.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) { @@ -222,7 +221,7 @@ pub(super) fn handle_permission_request_event( }); tc.invalidate_render_cache(); layout_dirty = true; - app.turn.pending_interaction_ids.push(tool_id.clone()); + app.pending_interaction_ids.push(tool_id.clone()); if auto_focus { app.claim_focus_target(FocusTarget::Permission); } @@ -400,7 +399,7 @@ pub(super) fn handle_question_request_event( return; }; - if app.turn.pending_interaction_ids.iter().any(|id| id == &tool_id) { + if app.pending_interaction_ids.iter().any(|id| id == &tool_id) { tracing::warn!( target: crate::logging::targets::APP_PERMISSION, event_name = "question_request_rejected", @@ -416,8 +415,7 @@ pub(super) fn handle_question_request_event( } let mut layout_dirty = false; - let auto_focus = - app.turn.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); + let auto_focus = app.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); if let Some(MessageBlock::ToolCall(tc)) = app.transcript.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) { @@ -438,7 +436,7 @@ pub(super) fn handle_question_request_event( }); tc.invalidate_render_cache(); layout_dirty = true; - app.turn.pending_interaction_ids.push(tool_id.clone()); + app.pending_interaction_ids.push(tool_id.clone()); if auto_focus { app.claim_focus_target(FocusTarget::Permission); } @@ -492,7 +490,7 @@ pub(super) fn handle_user_dialog_request_event( let dialog_kind = request.dialog_kind.clone(); let option_count = request.options.len(); - if app.turn.pending_interaction_ids.iter().any(|id| id == &request_id) { + if app.pending_interaction_ids.iter().any(|id| id == &request_id) { tracing::warn!( target: crate::logging::targets::APP_PERMISSION, event_name = "user_dialog_request_rejected", @@ -508,8 +506,7 @@ pub(super) fn handle_user_dialog_request_event( return; } - let auto_focus = - app.turn.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); + let auto_focus = app.pending_interaction_ids.is_empty() && !app.has_draft_input_for_focus(); let mi = app.transcript.messages.len(); let bi = 0; let mut block = UserDialogBlock::new(request, response_tx); @@ -520,7 +517,7 @@ pub(super) fn handle_user_dialog_request_event( None, )); app.index_tool_call(request_id.clone(), mi, bi); - app.turn.pending_interaction_ids.push(request_id.clone()); + app.pending_interaction_ids.push(request_id.clone()); if auto_focus { app.claim_focus_target(FocusTarget::Permission); } @@ -1112,6 +1109,7 @@ mod tests { cache: crate::app::BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -1461,6 +1459,7 @@ mod tests { cache: crate::app::BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }))], None, )); @@ -1609,6 +1608,48 @@ mod tests { assert!(context.parent_raw_input.is_some()); } + #[test] + fn nested_background_permission_keeps_its_agent_context_after_foreground_completion() { + let mut app = App::test_default(); + let mut root = tool_call_info("root-agent", "Background reviewer", "Agent", None, false); + root.status = model::ToolCallStatus::Detached; + push_tool(&mut app, root); + let mut nested = tool_call_info( + "nested-agent", + "Nested reviewer", + "Agent", + Some(serde_json::json!({"name": "nested-reviewer"})), + true, + ); + nested.status = model::ToolCallStatus::Detached; + push_tool(&mut app, nested); + push_tool(&mut app, tool_call_info("nested-bash", "Nested command", "Bash", None, true)); + app.register_tool_call_scope("root-agent".to_owned(), ToolCallScope::SubagentRoot); + app.register_tool_call_scope( + "nested-agent".to_owned(), + ToolCallScope::SubagentChild { parent_tool_use_id: "root-agent".to_owned() }, + ); + app.register_tool_call_scope( + "nested-bash".to_owned(), + ToolCallScope::SubagentChild { parent_tool_use_id: "nested-agent".to_owned() }, + ); + + handle_turn_complete_event(&mut app, None, None); + let (mi, bi) = app.lookup_tool_call("nested-bash").expect("nested command"); + let MessageBlock::ToolCall(child) = &app.transcript.messages[mi].blocks[bi] else { + panic!("nested command is a tool call"); + }; + assert_eq!(child.status, model::ToolCallStatus::InProgress); + let (tx, _rx) = tokio::sync::oneshot::channel(); + handle_permission_request_event(&mut app, permission_request("nested-bash"), tx); + + let context = + pending_permission_context(&app, "nested-bash").expect("nested context survives"); + assert_eq!(context.parent_tool_call_id, "nested-agent"); + assert_eq!(context.subagent_label, "nested-reviewer"); + assert_eq!(context.child_tool_name, "Bash"); + } + #[test] fn subagent_child_permission_missing_parent_falls_back_cleanly() { let mut app = App::test_default(); diff --git a/src/app/inline_interactions.rs b/src/app/inline_interactions.rs index 68cf1d16..111cdace 100644 --- a/src/app/inline_interactions.rs +++ b/src/app/inline_interactions.rs @@ -5,7 +5,7 @@ use super::{App, FocusTarget, InvalidationLevel, MessageBlock, ToolCallInfo}; use crossterm::event::{KeyCode, KeyEvent}; pub(super) fn focused_interaction_id(app: &App) -> Option<&str> { - app.turn.pending_interaction_ids.first().map(String::as_str) + app.pending_interaction_ids.first().map(String::as_str) } fn interaction_id_is_valid(app: &App, tool_id: &str) -> bool { @@ -60,7 +60,7 @@ pub(super) fn invalidate_if_changed( } pub(super) fn set_interaction_focused(app: &mut App, queue_index: usize, focused: bool) { - let Some(tool_id) = app.turn.pending_interaction_ids.get(queue_index).cloned() else { + let Some(tool_id) = app.pending_interaction_ids.get(queue_index).cloned() else { return; }; let Some((mi, bi)) = app.lookup_tool_call(&tool_id) else { @@ -144,7 +144,7 @@ pub(crate) fn cancel_pending_interaction(app: &mut App, id: &str) { } _ => return, } - app.turn.pending_interaction_ids.retain(|pending| pending != id); + app.pending_interaction_ids.retain(|pending| pending != id); app.sync_render_cache_slot(mi, bi); app.recompute_message_retained_bytes(mi); app.invalidate_layout(InvalidationLevel::MessageChanged(mi)); @@ -169,8 +169,8 @@ pub(super) fn has_focused_user_dialog(app: &App) -> bool { pub(super) fn clear_inline_interaction_focus(app: &mut App) { let mut changed = false; - for idx in 0..app.turn.pending_interaction_ids.len() { - let Some(tool_id) = app.turn.pending_interaction_ids.get(idx).cloned() else { + for idx in 0..app.pending_interaction_ids.len() { + let Some(tool_id) = app.pending_interaction_ids.get(idx).cloned() else { continue; }; let Some((mi, bi)) = app.lookup_tool_call(&tool_id) else { @@ -226,7 +226,7 @@ pub(super) fn focus_next_inline_interaction(app: &mut App) { normalize_pending_interaction_queue(app); clear_inline_interaction_focus(app); set_interaction_focused(app, 0, true); - if app.turn.pending_interaction_ids.is_empty() { + if app.pending_interaction_ids.is_empty() { app.release_focus_target(FocusTarget::Permission); } else { app.claim_focus_target(FocusTarget::Permission); @@ -234,7 +234,7 @@ pub(super) fn focus_next_inline_interaction(app: &mut App) { } pub(super) fn normalize_pending_interaction_queue(app: &mut App) { - let previous = std::mem::take(&mut app.turn.pending_interaction_ids); + let previous = std::mem::take(&mut app.pending_interaction_ids); let previous_order = previous.clone(); let mut queue = Vec::with_capacity(previous.len()); for id in previous { @@ -243,16 +243,16 @@ pub(super) fn normalize_pending_interaction_queue(app: &mut App) { } } let changed = queue != previous_order; - app.turn.pending_interaction_ids = queue; + app.pending_interaction_ids = queue; - if app.turn.pending_interaction_ids.is_empty() { + if app.pending_interaction_ids.is_empty() { clear_inline_interaction_focus(app); return; } if changed { let permission_has_focus = matches!(app.focus_owner(), super::FocusOwner::Permission); - for idx in 0..app.turn.pending_interaction_ids.len() { + for idx in 0..app.pending_interaction_ids.len() { set_interaction_focused(app, idx, permission_has_focus && idx == 0); } } @@ -264,8 +264,7 @@ pub(super) fn normalize_pending_interaction_queue(app: &mut App) { pub(super) fn pop_next_valid_interaction_id(app: &mut App) -> Option { normalize_pending_interaction_queue(app); - (!app.turn.pending_interaction_ids.is_empty()) - .then(|| app.turn.pending_interaction_ids.remove(0)) + (!app.pending_interaction_ids.is_empty()).then(|| app.pending_interaction_ids.remove(0)) } pub(super) fn handle_interaction_focus_cycle( @@ -280,7 +279,7 @@ pub(super) fn handle_interaction_focus_cycle( if !matches!(key.code, KeyCode::Up | KeyCode::Down) { return None; } - if app.turn.pending_interaction_ids.len() <= 1 { + if app.pending_interaction_ids.len() <= 1 { if blocks_vertical_navigation { return None; } @@ -290,13 +289,13 @@ pub(super) fn handle_interaction_focus_cycle( set_interaction_focused(app, 0, false); if key.code == KeyCode::Down { - let first = app.turn.pending_interaction_ids.remove(0); - app.turn.pending_interaction_ids.push(first); + let first = app.pending_interaction_ids.remove(0); + app.pending_interaction_ids.push(first); } else { - let Some(last) = app.turn.pending_interaction_ids.pop() else { + let Some(last) = app.pending_interaction_ids.pop() else { return Some(false); }; - app.turn.pending_interaction_ids.insert(0, last); + app.pending_interaction_ids.insert(0, last); } set_interaction_focused(app, 0, true); diff --git a/src/app/keys.rs b/src/app/keys.rs index b1902916..e0f4ca28 100644 --- a/src/app/keys.rs +++ b/src/app/keys.rs @@ -551,7 +551,7 @@ fn handle_submit(app: &mut App) -> bool { } fn handle_focus_toggle(app: &mut App) -> bool { - if app.turn.pending_interaction_ids.is_empty() { + if app.pending_interaction_ids.is_empty() { false } else { match app.focus_owner() { diff --git a/src/app/mod.rs b/src/app/mod.rs index 6facd16d..9175e7ee 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -69,7 +69,6 @@ pub use lifecycle::{ }; pub use service_status_check::start_service_status_check; pub use settings::{AppSettings, UpdatePrompt}; -pub(crate) use state::ComposerBlockReason; pub use state::{ ActiveCompaction, App, AppStatus, AutocompleteKind, BlockCache, BtwExchangeBlock, CacheMetrics, ChatMessage, ChatMessageId, ChatRenderState, CompactionState, ComposerRenderState, ExtraUsage, @@ -88,6 +87,7 @@ pub use state::{ pub(crate) use state::{ BtwRequestState, BtwRequests, PendingUserMessage, PendingUserMessageInsertError, }; +pub(crate) use state::{ComposerBlockReason, tool_call_info::ToolCallHistory}; pub use trust::TrustSelection; pub use update_check::{automatic_update_action, start_update_check}; pub(crate) use update_prompt::actions_for as update_prompt_actions; @@ -442,7 +442,7 @@ fn prepare_app_shutdown(app: &mut App) { app.request_chat_visible_rebuild(); // Dismiss all pending inline permissions (reject via last option) - for tool_id in std::mem::take(&mut app.turn.pending_interaction_ids) { + for tool_id in std::mem::take(&mut app.pending_interaction_ids) { if let Some((mi, bi)) = app.lookup_tool_call(&tool_id) && let Some(MessageBlock::ToolCall(tc)) = app.transcript.messages.get_mut(mi).and_then(|m| m.blocks.get_mut(bi)) diff --git a/src/app/permissions.rs b/src/app/permissions.rs index 44eca5e9..3c7b81d3 100644 --- a/src/app/permissions.rs +++ b/src/app/permissions.rs @@ -300,6 +300,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -346,7 +347,7 @@ mod tests { focused, }); } - app.turn.pending_interaction_ids.push(tool_id.to_owned()); + app.pending_interaction_ids.push(tool_id.to_owned()); rx } @@ -375,7 +376,7 @@ mod tests { let mut rx1 = add_permission(&mut app, "perm-1", allow_options(), true); let mut rx2 = add_permission(&mut app, "perm-2", allow_options(), false); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1", "perm-2"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1", "perm-2"]); assert!(permission_focused(&app, "perm-1")); assert!(!permission_focused(&app, "perm-2")); @@ -385,7 +386,7 @@ mod tests { KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), ); assert_eq!(consumed, KeyOutcome::Handled(true)); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-2", "perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-2", "perm-1"]); assert!(permission_focused(&app, "perm-2")); assert!(!permission_focused(&app, "perm-1")); @@ -402,7 +403,7 @@ mod tests { }; assert_eq!(sel2.option_id.clone(), "allow-once"); assert!(matches!(rx1.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); } #[test] @@ -444,7 +445,7 @@ mod tests { KeyOutcome::Ignored ); - assert_eq!(app.turn.pending_interaction_ids, vec!["perm-1"]); + assert_eq!(app.pending_interaction_ids, vec!["perm-1"]); assert!(matches!(rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); } @@ -497,6 +498,6 @@ mod tests { panic!("expected selected permission response"); }; assert_eq!(selected.option_id, "allow-once"); - assert_eq!(app.turn.pending_interaction_ids, Vec::::new()); + assert_eq!(app.pending_interaction_ids, Vec::::new()); } } diff --git a/src/app/questions.rs b/src/app/questions.rs index 83f15313..9382f4ff 100644 --- a/src/app/questions.rs +++ b/src/app/questions.rs @@ -14,7 +14,7 @@ use std::time::Instant; /// Question activity and expiry belong to the rendered interaction, not app settings. pub(super) fn record_activity(app: &mut App, now: Instant) { - let ids = app.turn.pending_interaction_ids.clone(); + let ids = app.pending_interaction_ids.clone(); for id in ids { let Some((mi, bi)) = app.lookup_tool_call(&id) else { continue; @@ -587,6 +587,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -623,7 +624,7 @@ mod tests { total_questions: 1, }); } - app.turn.pending_interaction_ids.push(tool_id.to_owned()); + app.pending_interaction_ids.push(tool_id.to_owned()); rx } @@ -660,7 +661,7 @@ mod tests { assert_eq!(consumed_right, KeyOutcome::Handled(true)); assert_eq!(consumed_enter, KeyOutcome::Handled(true)); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); let resp = rx.try_recv().expect("question should be answered"); let model::RequestQuestionOutcome::Answered(answered) = resp.outcome else { @@ -811,7 +812,7 @@ mod tests { answer.selected_option_ids, if selected { vec!["b".to_owned()] } else { Vec::new() } ); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } } diff --git a/src/app/state/app.rs b/src/app/state/app.rs index 9b853ad8..da0643c1 100644 --- a/src/app/state/app.rs +++ b/src/app/state/app.rs @@ -36,7 +36,7 @@ pub struct App { pub cwd_raw: String, pub files_accessed: usize, /// State scoped to the currently active turn (command spinner, cancel - /// bookkeeping, inline interactions, turn-local notices). + /// bookkeeping, turn-local notices). pub turn: TurnState, pub(crate) event_tx: mpsc::Sender, pub(crate) event_rx: mpsc::Receiver, @@ -47,6 +47,9 @@ pub struct App { /// Tool scope keyed by tool call ID; used to distinguish main-agent, subagent roots, /// and explicitly owned subagent child tools. pub(crate) tool_call_scopes: HashMap, + /// Session-scoped tool interaction IDs, ordered by arrival; the first receives input. + /// Background controls survive foreground turn exits and clear on session reset. + pub pending_interaction_ids: Vec, /// Focus manager for directional/navigation key ownership. pub(crate) focus: FocusManager, /// Resolved keyboard bindings used by chat-surface dispatch. @@ -244,6 +247,7 @@ impl App { spinner_frame: 0, spinner_last_advance_at: None, tool_call_scopes: HashMap::default(), + pending_interaction_ids: Vec::new(), focus: FocusManager::default(), keymap: ResolvedKeymap::defaults(), plugins: PluginsState::default(), diff --git a/src/app/state/focus_runtime.rs b/src/app/state/focus_runtime.rs index a0460417..9f9ee629 100644 --- a/src/app/state/focus_runtime.rs +++ b/src/app/state/focus_runtime.rs @@ -22,7 +22,7 @@ impl App { self.normalize_focus_stack(); - if self.turn.pending_interaction_ids.is_empty() { + if self.pending_interaction_ids.is_empty() { clear_inline_interaction_focus(self); } else if self.focus_owner() == FocusOwner::Permission || !self.has_draft_input_for_focus() { @@ -63,7 +63,7 @@ impl App { fn focus_context(&self) -> FocusContext { FocusContext::new( self.autocomplete_focus_available(), - !self.turn.pending_interaction_ids.is_empty(), + !self.pending_interaction_ids.is_empty(), ) } } diff --git a/src/app/state/history_retention.rs b/src/app/state/history_retention.rs index 9ef5657a..570ef59f 100644 --- a/src/app/state/history_retention.rs +++ b/src/app/state/history_retention.rs @@ -155,6 +155,9 @@ impl super::App { } } + if let super::tool_call_info::ToolCallHistory::Detached { launch, .. } = &tc.history { + total = total.saturating_add(Self::measure_tool_call_bytes(launch)); + } total } @@ -196,7 +199,7 @@ impl super::App { }); } } - MessageBlock::ToolCall(tc) => { + MessageBlock::ToolCall(tc) | MessageBlock::ToolResult { tool: tc, .. } => { total = total.saturating_add(Self::measure_tool_call_bytes(tc)); } MessageBlock::Welcome(welcome) => { @@ -393,17 +396,15 @@ impl super::App { fn rebuild_active_task_tracking_from_tool_scopes(&mut self) { self.tool_call_scopes.retain(|id, _| self.transcript.tool_call_index.contains_key(id)); + let mut roots = Vec::new(); for msg in &self.transcript.messages { for block in &msg.blocks { let MessageBlock::ToolCall(tc) = block else { continue; }; - if tc.status.is_terminal() { - continue; - } match self.tool_call_scopes.get(&tc.id) { Some(super::ToolCallScope::SubagentRoot) => { - self.turn.active_task_ids.insert(tc.id.clone()); + roots.push((tc.id.clone(), tc.status)); } Some( super::ToolCallScope::SubagentChild { .. } @@ -413,19 +414,22 @@ impl super::App { } } } + for (id, status) in roots { + self.sync_active_task_status(&id, status); + } } fn sync_pending_interaction_focus(&mut self, pending_interaction_ids: Vec) { let interaction_set: HashSet<&str> = pending_interaction_ids.iter().map(String::as_str).collect(); - self.turn.pending_interaction_ids.retain(|id| interaction_set.contains(id.as_str())); + self.pending_interaction_ids.retain(|id| interaction_set.contains(id.as_str())); for id in pending_interaction_ids { - if !self.turn.pending_interaction_ids.iter().any(|existing| existing == &id) { - self.turn.pending_interaction_ids.push(id); + if !self.pending_interaction_ids.iter().any(|existing| existing == &id) { + self.pending_interaction_ids.push(id); } } - if let Some(first_id) = self.turn.pending_interaction_ids.first().cloned() { + if let Some(first_id) = self.pending_interaction_ids.first().cloned() { self.claim_focus_target(super::super::focus::FocusTarget::Permission); if let Some((msg_idx, block_idx)) = self.lookup_tool_call(&first_id) && let Some(block) = self diff --git a/src/app/state/messages.rs b/src/app/state/messages.rs index dae12780..ad52b801 100644 --- a/src/app/state/messages.rs +++ b/src/app/state/messages.rs @@ -56,6 +56,7 @@ pub enum HistoryOutputId { AssistantDuration(ChatMessageId), Block(MessageBlockId), ToolCall(String), + ToolInteraction(String), } pub struct ChatMessage { @@ -296,6 +297,11 @@ pub enum MessageBlock { BtwExchange(BtwExchangeBlock), Notice(NoticeBlock), ToolCall(Box), + /// Immutable result of an independently running tool, linked by tool ID. + ToolResult { + id: MessageBlockId, + tool: Box, + }, Welcome(WelcomeBlock), /// Indicates N images were attached to this user message. ImageAttachment(ImageAttachmentBlock), diff --git a/src/app/state/render_budget.rs b/src/app/state/render_budget.rs index 7b40df4e..776b3fb8 100644 --- a/src/app/state/render_budget.rs +++ b/src/app/state/render_budget.rs @@ -46,7 +46,8 @@ impl super::App { MessageBlock::BtwExchange(block) => &block.cache, MessageBlock::Notice(block) => &block.text.cache, MessageBlock::Welcome(welcome) => &welcome.cache, - MessageBlock::ToolCall(tc) => &tc.cache, + MessageBlock::ToolCall(tc) => &tc.display().cache, + MessageBlock::ToolResult { tool, .. } => &tool.cache, MessageBlock::ImageAttachment(img) => &img.cache, MessageBlock::UserDialog(dialog) => &dialog.cache, } @@ -60,12 +61,12 @@ impl super::App { else { return false; }; - let tool_protected = matches!( - block, - MessageBlock::ToolCall(tc) - if !tc.status.is_terminal() - ); - tail_protected || tool_protected + tail_protected || Self::tool_cache_is_protected(block) + } + + #[must_use] + fn tool_cache_is_protected(block: &MessageBlock) -> bool { + matches!(block, MessageBlock::ToolCall(tool) if !tool.status.is_terminal() && !tool.has_frozen_launch()) } #[must_use] @@ -106,12 +107,8 @@ impl super::App { for (block_idx, block) in msg.blocks.iter().enumerate() { let cache = Self::block_cache(block); let cached_bytes = cache.cached_bytes(); - let protected = protected_tail == Some(msg_idx) - || matches!( - block, - MessageBlock::ToolCall(tc) - if !tc.status.is_terminal() - ); + let protected = + protected_tail == Some(msg_idx) || Self::tool_cache_is_protected(block); let slot = RenderCacheSlotState { cached_bytes, last_access_tick: cache.last_access_tick(), @@ -319,7 +316,8 @@ impl super::App { MessageBlock::BtwExchange(block) => block.cache.evict_cached_render(), MessageBlock::Notice(block) => block.text.cache.evict_cached_render(), MessageBlock::Welcome(welcome) => welcome.cache.evict_cached_render(), - MessageBlock::ToolCall(tc) => tc.cache.evict_cached_render(), + MessageBlock::ToolCall(tc) => tc.display_mut().cache.evict_cached_render(), + MessageBlock::ToolResult { tool, .. } => tool.cache.evict_cached_render(), MessageBlock::ImageAttachment(img) => img.cache.evict_cached_render(), MessageBlock::UserDialog(dialog) => dialog.cache.evict_cached_render(), }; diff --git a/src/app/state/tests.rs b/src/app/state/tests.rs index b186ebe3..69a66508 100644 --- a/src/app/state/tests.rs +++ b/src/app/state/tests.rs @@ -886,14 +886,14 @@ fn enforce_history_retention_preserves_and_rebuilds_pending_user_dialog() { pending_user_dialog_message("dialog-1"), ]; app.index_tool_call("dialog-1".to_owned(), 99, 99); - app.turn.pending_interaction_ids = vec!["stale-dialog".to_owned(), "dialog-1".to_owned()]; + app.pending_interaction_ids = vec!["stale-dialog".to_owned(), "dialog-1".to_owned()]; app.history_retention.max_bytes = 1; let stats = app.enforce_history_retention(); assert_eq!(stats.dropped_messages, 1); assert_eq!(app.lookup_tool_call("dialog-1"), Some((2, 0))); - assert_eq!(app.turn.pending_interaction_ids, vec!["dialog-1".to_owned()]); + assert_eq!(app.pending_interaction_ids, vec!["dialog-1".to_owned()]); let Some((msg_idx, block_idx)) = app.lookup_tool_call("dialog-1") else { panic!("expected rebuilt dialog index"); }; @@ -1135,6 +1135,8 @@ fn detached_calls_survive_turn_exits_but_finish_when_the_session_ends() { vec![MessageBlock::ToolCall(Box::new(tool))], None, )); + app.index_tool_call("web-1".to_owned(), 0, 0); + app.sync_tool_call_history("web-1"); for status in [model::ToolCallStatus::Completed, model::ToolCallStatus::Failed] { app.finalize_turn_runtime_artifacts(status); let MessageBlock::ToolCall(tool) = &app.transcript.messages[0].blocks[0] else { @@ -1147,6 +1149,19 @@ fn detached_calls_survive_turn_exits_but_finish_when_the_session_ends() { panic!("expected tool call") }; assert_eq!(tool.status, model::ToolCallStatus::Failed); + let results: Vec<_> = app + .transcript + .messages + .iter() + .flat_map(|message| &message.blocks) + .filter_map(|block| match block { + MessageBlock::ToolResult { tool, .. } => Some(tool), + _ => None, + }) + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].id, "web-1"); + assert_eq!(results[0].status, model::ToolCallStatus::Failed); } #[test] @@ -1211,13 +1226,13 @@ fn clear_messages_tracked_clears_tool_tracking() { "term-1", )); app.index_tool_call("bash-1".to_owned(), 0, 0); - app.turn.pending_interaction_ids.push("bash-1".into()); + app.pending_interaction_ids.push("bash-1".into()); app.clear_messages_tracked(); assert!(app.transcript.messages.is_empty()); assert!(app.transcript.tool_call_index.is_empty()); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } #[test] @@ -1237,7 +1252,7 @@ fn rebuild_tool_indices_includes_completed_tools() { fn focus_test_app_with_available_targets() -> App { let mut app = make_test_app(); - app.turn.pending_interaction_ids.push("perm-1".into()); + app.pending_interaction_ids.push("perm-1".into()); app.slash.show(SlashState { trigger_row: 0, trigger_col: 0, diff --git a/src/app/state/tool_call_info.rs b/src/app/state/tool_call_info.rs index bc078d5a..155f5113 100644 --- a/src/app/state/tool_call_info.rs +++ b/src/app/state/tool_call_info.rs @@ -2,6 +2,7 @@ // Copyright 2025 Simon Peter Rothgang use super::block_cache::BlockCache; +use super::messages::MessageBlockId; use crate::agent::model; pub struct ToolCallInfo { @@ -34,6 +35,18 @@ pub struct ToolCallInfo { pub pending_permission: Option, /// Inline question prompt from `AskUserQuestion`. pub pending_question: Option, + /// Immutable transcript projections; execution fields above remain authoritative. + pub(crate) history: ToolCallHistory, +} + +#[derive(Default)] +pub(crate) enum ToolCallHistory { + #[default] + Live, + Detached { + launch: Box, + result_id: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -48,6 +61,73 @@ pub struct SubagentPermissionContext { } impl ToolCallInfo { + pub(crate) fn has_frozen_launch(&self) -> bool { + matches!(self.history, ToolCallHistory::Detached { .. }) + } + pub(crate) fn display(&self) -> &Self { + match &self.history { + ToolCallHistory::Live => self, + ToolCallHistory::Detached { launch, .. } => launch, + } + } + + pub(crate) fn display_mut(&mut self) -> &mut Self { + if matches!(self.history, ToolCallHistory::Live) { + return self; + } + match self { + Self { history: ToolCallHistory::Detached { launch, .. }, .. } => launch, + tool => tool, + } + } + + fn snapshot(&self) -> Self { + Self { + id: self.id.clone(), + source_message_uuids: self.source_message_uuids.clone(), + title: self.title.clone(), + sdk_tool_name: self.sdk_tool_name.clone(), + raw_input: self.raw_input.clone(), + raw_input_bytes: self.raw_input_bytes, + locations: self.locations.clone(), + output_metadata: self.output_metadata.clone(), + task_metadata: self.task_metadata.clone(), + status: self.status, + content: self.content.clone(), + hidden: self.hidden, + terminal_id: None, + terminal_command: self.terminal_command.clone(), + terminal_output: self.terminal_output.clone(), + terminal_output_len: self.terminal_output_len, + cache: BlockCache::default(), + pending_permission: None, + pending_question: None, + history: ToolCallHistory::Live, + } + } + + /// Freeze the handoff once; progress can subsequently change execution only. + pub(crate) fn sync_history(&mut self) -> Option<(MessageBlockId, Box)> { + if self.status == model::ToolCallStatus::Detached + && matches!(self.history, ToolCallHistory::Live) + { + self.history = + ToolCallHistory::Detached { launch: Box::new(self.snapshot()), result_id: None }; + self.cache = BlockCache::default(); + } + if self.status.is_terminal() + && matches!(self.history, ToolCallHistory::Detached { result_id: None, .. }) + { + let result = Box::new(self.snapshot()); + let id = MessageBlockId::new(); + if let ToolCallHistory::Detached { result_id, .. } = &mut self.history { + *result_id = Some(id); + } + return Some((id, result)); + } + None + } + pub fn add_source_message_uuid(&mut self, source_message_uuid: Option<&str>) -> bool { let Some(source_message_uuid) = source_message_uuid.map(str::trim).filter(|uuid| !uuid.is_empty()) diff --git a/src/app/state/tool_tracking.rs b/src/app/state/tool_tracking.rs index e154dbee..b49018c7 100644 --- a/src/app/state/tool_tracking.rs +++ b/src/app/state/tool_tracking.rs @@ -4,11 +4,45 @@ use super::prelude::*; impl App { + pub(crate) fn sync_tool_call_history(&mut self, id: &str) { + let Some((mi, bi)) = self.lookup_tool_call(id) else { + return; + }; + let result = match self + .transcript + .messages + .get_mut(mi) + .and_then(|message| message.blocks.get_mut(bi)) + { + Some(MessageBlock::ToolCall(tool)) => tool.sync_history(), + _ => None, + }; + self.sync_render_cache_slot(mi, bi); + self.recompute_message_retained_bytes(mi); + let Some((id, tool)) = result else { + return; + }; + let block = MessageBlock::ToolResult { id, tool }; + if let Some(owner) = self.active_turn_assistant_idx() { + self.transcript.messages[owner].blocks.push(block); + self.sync_after_message_blocks_changed(owner); + } else { + self.push_message_tracked(ChatMessage::new(MessageRole::Assistant, vec![block], None)); + } + } /// Track a Task/Agent tool call as active (in-progress subagent). pub fn insert_active_task(&mut self, id: String) { self.turn.active_task_ids.insert(id); } + pub(crate) fn sync_active_task_status(&mut self, id: &str, status: model::ToolCallStatus) { + if matches!(status, model::ToolCallStatus::Pending | model::ToolCallStatus::InProgress) { + self.insert_active_task(id.to_owned()); + } else { + self.remove_active_task(id); + } + } + /// Remove a Task/Agent tool call from the active set (completed/failed). pub fn remove_active_task(&mut self, id: &str) { self.turn.active_task_ids.remove(id); @@ -66,6 +100,33 @@ impl App { self.finalize_tool_calls(new_status, false) } + fn tool_belongs_to_background_execution<'a>(&'a self, mut id: &'a str) -> bool { + for _ in 0..=self.tool_call_scopes.len() { + if self.lookup_tool_call(id).is_some_and(|(mi, bi)| { + matches!(&self.transcript.messages[mi].blocks[bi], MessageBlock::ToolCall(tool) + if tool.status == model::ToolCallStatus::Detached) + }) { + return true; + } + let Some(ToolCallScope::SubagentChild { parent_tool_use_id }) = + self.tool_call_scopes.get(id) + else { + return false; + }; + id = parent_tool_use_id; + } + false + } + + fn background_tool_ids(&self) -> std::collections::HashSet { + self.transcript + .tool_call_index + .keys() + .filter(|id| self.tool_belongs_to_background_execution(id)) + .cloned() + .collect() + } + fn finalize_tool_calls( &mut self, new_status: model::ToolCallStatus, @@ -75,15 +136,20 @@ impl App { let mut cleared_interaction = false; let mut changed_message_indices = Vec::new(); let mut changed_slots = Vec::new(); + let mut settled_tools = Vec::new(); + let background_tools: std::collections::HashSet = if include_detached { + std::collections::HashSet::new() + } else { + self.background_tool_ids() + }; for (msg_idx, msg) in self.transcript.messages.iter_mut().enumerate() { for (block_idx, block) in msg.blocks.iter_mut().enumerate() { if let MessageBlock::ToolCall(tc) = block { let tc = tc.as_mut(); - if !tc.status.is_terminal() - && (include_detached || tc.status != model::ToolCallStatus::Detached) - { + if !tc.status.is_terminal() && !background_tools.contains(&tc.id) { tc.status = new_status; + settled_tools.push(tc.id.clone()); tc.invalidate_render_cache(); changed_slots.push((msg_idx, block_idx)); if tc.pending_permission.take().is_some() { @@ -104,6 +170,9 @@ impl App { for (msg_idx, block_idx) in changed_slots { self.sync_render_cache_slot(msg_idx, block_idx); } + for id in settled_tools { + self.sync_tool_call_history(&id); + } for msg_idx in changed_message_indices.iter().copied() { self.recompute_message_retained_bytes(msg_idx); @@ -111,8 +180,8 @@ impl App { if changed > 0 || cleared_interaction { self.invalidate_message_set(changed_message_indices.iter().copied()); - self.turn.pending_interaction_ids.clear(); - self.release_focus_target(FocusTarget::Permission); + self.pending_interaction_ids.retain(|id| background_tools.contains(id)); + crate::app::inline_interactions::normalize_pending_interaction_queue(self); } changed @@ -121,6 +190,13 @@ impl App { /// Clear any inline permission/question UI still attached to tool calls. /// Returns the number of tool call blocks that changed. pub fn clear_inline_tool_interactions(&mut self) -> usize { + self.clear_inline_tool_interactions_excluding(&std::collections::HashSet::new()) + } + + fn clear_inline_tool_interactions_excluding( + &mut self, + preserved_tools: &std::collections::HashSet, + ) -> usize { let mut changed = 0usize; let mut changed_message_indices = Vec::new(); let mut changed_slots = Vec::new(); @@ -131,6 +207,9 @@ impl App { continue; }; let tc = tc.as_mut(); + if preserved_tools.contains(&tc.id) { + continue; + } let mut block_changed = false; if tc.pending_permission.take().is_some() { block_changed = true; @@ -162,9 +241,9 @@ impl App { self.invalidate_message_set(changed_message_indices.iter().copied()); } - if changed > 0 || !self.turn.pending_interaction_ids.is_empty() { - self.turn.pending_interaction_ids.clear(); - self.release_focus_target(FocusTarget::Permission); + if changed > 0 || !self.pending_interaction_ids.is_empty() { + self.pending_interaction_ids.retain(|id| preserved_tools.contains(id)); + crate::app::inline_interactions::normalize_pending_interaction_queue(self); } changed @@ -184,8 +263,19 @@ impl App { new_status: model::ToolCallStatus, include_detached: bool, ) { + let preserved_tools = if include_detached { + std::collections::HashSet::new() + } else { + self.background_tool_ids() + }; let _ = self.finalize_tool_calls(new_status, include_detached); - let _ = self.clear_inline_tool_interactions(); - self.clear_tool_scope_tracking(); + let _ = self.clear_inline_tool_interactions_excluding(&preserved_tools); + if include_detached { + self.clear_tool_scope_tracking(); + } else { + // Tool scope belongs to the session: detached agents and their descendants + // can still emit events after the foreground turn ends. + self.turn.active_task_ids.clear(); + } } } diff --git a/src/app/state/turn.rs b/src/app/state/turn.rs index 82fe18e6..85832955 100644 --- a/src/app/state/turn.rs +++ b/src/app/state/turn.rs @@ -83,10 +83,6 @@ pub struct TurnState { pub pending_command_ack: Option, /// The single live lifecycle state for manual and automatic context compaction. pub compaction: CompactionState, - /// Tool call IDs with pending inline interactions, ordered by arrival. - /// The first entry is the focused interaction that receives keyboard input. - /// Up / Down arrow keys cycle focus through the list. - pub pending_interaction_ids: Vec, /// Whether an explicit cancellation request is awaiting turn exit. pub cancel_requested: bool, /// Message index that owns the current main-assistant turn output. @@ -112,7 +108,6 @@ impl TurnState { self.pending_command_label = None; self.pending_command_ack = None; self.compaction.reset(); - self.pending_interaction_ids.clear(); self.cancel_requested = false; self.assistant_message_idx = None; self.active_task_ids.clear(); diff --git a/src/app/tasks.rs b/src/app/tasks.rs index a945114e..2cb17b2d 100644 --- a/src/app/tasks.rs +++ b/src/app/tasks.rs @@ -269,6 +269,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } diff --git a/src/app/terminal_runtime/chat_session.rs b/src/app/terminal_runtime/chat_session.rs index 0a227016..8722dab2 100644 --- a/src/app/terminal_runtime/chat_session.rs +++ b/src/app/terminal_runtime/chat_session.rs @@ -1398,7 +1398,7 @@ mod tests { let mut app = App::test_default(); app.input.set_text("hello"); let _ = app.input.set_cursor(0, 5); - app.turn.pending_interaction_ids.push("perm-1".to_owned()); + app.pending_interaction_ids.push("perm-1".to_owned()); app.claim_focus_target(FocusTarget::Permission); let backend = render_textarea_to_test_backend(&mut app, Rect::new(0, 0, 20, 3)); diff --git a/src/app/test_support.rs b/src/app/test_support.rs index dd782d9c..6122afa3 100644 --- a/src/app/test_support.rs +++ b/src/app/test_support.rs @@ -27,5 +27,6 @@ pub(crate) fn tool_call_info(id: &str, status: model::ToolCallStatus) -> ToolCal cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } diff --git a/src/app/user_dialog.rs b/src/app/user_dialog.rs index cc9f82e0..b0c96190 100644 --- a/src/app/user_dialog.rs +++ b/src/app/user_dialog.rs @@ -215,7 +215,7 @@ mod tests { None, )); app.index_tool_call(request_id.to_owned(), msg_idx, 0); - app.turn.pending_interaction_ids.push(request_id.to_owned()); + app.pending_interaction_ids.push(request_id.to_owned()); rx } @@ -248,7 +248,7 @@ mod tests { }, ); assert!(matches!(expired.try_recv(), Err(oneshot::error::TryRecvError::Closed))); - assert_eq!(app.turn.pending_interaction_ids, vec!["next"]); + assert_eq!(app.pending_interaction_ids, vec!["next"]); assert_eq!(app.input.text(), "keep this draft"); let MessageBlock::UserDialog(dialog) = &app.transcript.messages[0].blocks[0] else { panic!("expired dialog"); @@ -262,7 +262,7 @@ mod tests { next.try_recv().expect("next dialog answered").outcome, model::RequestUserDialogOutcome::Selected(_) )); - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } #[test] @@ -282,7 +282,7 @@ mod tests { }; assert_eq!(selected.option_id, "retry_fallback"); assert_eq!(app.measure_history_bytes(), pending_bytes + selected.option_id.capacity()); - assert!(!app.turn.pending_interaction_ids.iter().any(|id| id == "dialog-1")); + assert!(!app.pending_interaction_ids.iter().any(|id| id == "dialog-1")); } #[test] @@ -325,7 +325,7 @@ mod tests { let response = rx.try_recv().expect("dialog should receive a response"); assert!(matches!(response.outcome, model::RequestUserDialogOutcome::Cancelled)); assert!(app.input.text().is_empty()); - assert!(!app.turn.pending_interaction_ids.iter().any(|id| id == "dialog-1")); + assert!(!app.pending_interaction_ids.iter().any(|id| id == "dialog-1")); } #[test] diff --git a/src/app/view/tests.rs b/src/app/view/tests.rs index bb5b5aa1..e0f347a2 100644 --- a/src/app/view/tests.rs +++ b/src/app/view/tests.rs @@ -41,7 +41,7 @@ fn busy_view_test_app() -> App { candidates: vec![], dialog: DialogState::default(), }); - app.turn.pending_interaction_ids.push("perm-1".to_owned()); + app.pending_interaction_ids.push("perm-1".to_owned()); app.claim_focus_target(FocusTarget::Permission); app } diff --git a/src/ui/footer_rows.rs b/src/ui/footer_rows.rs index d8e80fda..0236115e 100644 --- a/src/ui/footer_rows.rs +++ b/src/ui/footer_rows.rs @@ -350,8 +350,7 @@ fn fit_footer_suffix_text(text: &str, max_width: usize) -> Option { } fn pending_permission_request_count(app: &App) -> usize { - app.turn - .pending_interaction_ids + app.pending_interaction_ids .iter() .filter(|tool_id| { let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { @@ -656,11 +655,12 @@ mod tests { focused: true, }), pending_question: None, + history: crate::app::ToolCallHistory::Live, }))], None, )); app.index_tool_call("perm-1".into(), 0, 0); - app.turn.pending_interaction_ids.push("perm-1".into()); + app.pending_interaction_ids.push("perm-1".into()); let serialized = serialize_footer_rows(&app, 80); let text = line_text(&serialized.rows[0]); diff --git a/src/ui/help.rs b/src/ui/help.rs index cf30d0bf..61ca4032 100644 --- a/src/ui/help.rs +++ b/src/ui/help.rs @@ -89,7 +89,7 @@ fn keymap_help_rows(app: &App, context: KeyContext) -> Vec<(String, String)> { fn should_show_help_binding(app: &App, context: KeyContext, binding: &ResolvedHelpBinding) -> bool { if context == KeyContext::ChatInput && matches!(binding.action, KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion)) - && app.turn.pending_interaction_ids.is_empty() + && app.pending_interaction_ids.is_empty() { return false; } @@ -105,7 +105,7 @@ fn help_action_label(app: &App, action: KeyAction) -> &'static str { } KeyAction::App(AppAction::CancelTurn) => "Clear pending input state", KeyAction::App(AppAction::FocusPromptOrAcceptSuggestion) - if !app.turn.pending_interaction_ids.is_empty() => + if !app.pending_interaction_ids.is_empty() => { "Focus pending prompt" } @@ -128,7 +128,7 @@ fn active_key_help_context(app: &App) -> KeyContext { } fn focused_question_prompt(app: &App) -> bool { - let Some(tool_id) = app.turn.pending_interaction_ids.first() else { + let Some(tool_id) = app.pending_interaction_ids.first() else { return false; }; let Some((mi, bi)) = app.lookup_tool_call(tool_id) else { diff --git a/src/ui/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs index d5e0ba0d..d068f37c 100644 --- a/src/ui/inline_chat_rows.rs +++ b/src/ui/inline_chat_rows.rs @@ -59,6 +59,7 @@ pub(crate) fn serialize_live_rows_with_boundaries_excluding( current_mode_id: current_mode_id.as_deref(), width, excluded_ids, + previous_block_kind, }, ); @@ -71,6 +72,13 @@ pub(crate) fn serialize_live_rows_with_boundaries_excluding( ); } + append_detached_tool_interactions( + app, + width, + current_mode_id.as_deref(), + &mut rows, + &mut row_boundaries, + ); let segments = live_boundaries_to_segments(row_boundaries, rows.len()); SerializedLiveRows::new(rows, segments) } @@ -80,6 +88,7 @@ struct LiveRowsRenderContext<'a> { current_mode_id: Option<&'a str>, width: u16, excluded_ids: &'a BTreeSet, + previous_block_kind: Option, } fn render_live_message_rows( @@ -105,6 +114,7 @@ fn render_live_message_rows( context.current_mode_id, context.width, context.excluded_ids, + context.previous_block_kind, ), } } @@ -194,6 +204,7 @@ fn render_assistant_live_rows( current_mode_id: Option<&str>, width: u16, excluded_ids: &BTreeSet, + previous_block_kind: Option, ) -> RenderedMessageRows { let active_mutable = active_assistant_message_is_mutable(app, msg_idx); let message_id = app.transcript.messages[msg_idx].id; @@ -201,7 +212,11 @@ fn render_assistant_live_rows( let owns_activity = presentation.as_ref().is_some_and(|presentation| { presentation.heading == crate::app::activity::ActivityHeading::Assistant(msg_idx) }); - let label_visibility = if excluded_ids.contains(&HistoryOutputId::AssistantLabel(message_id)) { + // The traversal also remembers excluded static content and skips invisible messages. + // Message ownership and output IDs remain independent of visual speaker grouping. + let label_visibility = if previous_block_kind == Some(TopLevelInlineBlockKind::Assistant) + || excluded_ids.contains(&HistoryOutputId::AssistantLabel(message_id)) + { AssistantLabelVisibility::Hidden } else if owns_activity { AssistantLabelVisibility::WithActivity @@ -358,6 +373,7 @@ fn welcome_output_ids(message: &ChatMessage) -> Vec { | MessageBlock::BtwExchange(_) | MessageBlock::Notice(_) | MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } | MessageBlock::ImageAttachment(_) | MessageBlock::UserDialog(_) => None, }) @@ -406,6 +422,7 @@ fn welcome_message_commit_ready(message: &ChatMessage) -> bool { | MessageBlock::BtwExchange(_) | MessageBlock::Notice(_) | MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } | MessageBlock::ImageAttachment(_) | MessageBlock::UserDialog(_) => None, }) @@ -627,46 +644,43 @@ fn assistant_render_items_from_message( flush_pending_text_run(&mut pending_text, &mut items); let current_kind = AssistantInlineItemKind::TextLike; let leading_blank_lines = leading_blank_lines_between(previous_kind, current_kind); - items.push(AssistantRenderItemSpec { - ids: vec![HistoryOutputId::Block(notice.id)], + items.push(notice_render_item( + notice, msg_idx, + block_idx, leading_blank_lines, - block_idx: Some(block_idx), - boundary_kind: LiveRowBoundaryKind::AssistantNotice, - commit_ready: active_tail_block_idx != Some(block_idx), - item: AssistantRenderItem::Notice(NoticeBlock { - id: notice.id, - severity: notice.severity, - text: TextBlock::from_complete(¬ice.text.text) - .with_trailing_spacing(notice.text.trailing_spacing), - dedup_key: notice.dedup_key.clone(), - }), - }); + active_tail_block_idx != Some(block_idx), + )); previous_kind = Some(current_kind); } MessageBlock::BtwExchange(exchange) => { flush_pending_text_run(&mut pending_text, &mut items); - let current_kind = AssistantInlineItemKind::TextLike; - let leading_blank_lines = leading_blank_lines_between(previous_kind, current_kind); - items.push(btw_render_item(exchange, msg_idx, block_idx, leading_blank_lines)); - previous_kind = Some(current_kind); + items.push(btw_render_item( + exchange, + msg_idx, + block_idx, + leading_blank_lines_between(previous_kind, AssistantInlineItemKind::TextLike), + )); + previous_kind = Some(AssistantInlineItemKind::TextLike); } - MessageBlock::ToolCall(tool) => { - if tool.hidden_unless_focused_interaction() { + MessageBlock::ToolCall(tool) | MessageBlock::ToolResult { tool, .. } => { + if tool.display().hidden_unless_focused_interaction() { continue; } flush_pending_text_run(&mut pending_text, &mut items); let current_kind = AssistantInlineItemKind::Tool; - let leading_blank_lines = leading_blank_lines_between(previous_kind, current_kind); - items.push(AssistantRenderItemSpec { - ids: vec![HistoryOutputId::ToolCall(tool.id.clone())], + let (id, commit_ready) = if let MessageBlock::ToolResult { id, .. } = block { + (HistoryOutputId::Block(*id), true) + } else { + (HistoryOutputId::ToolCall(tool.id.clone()), tool_call_commit_ready(tool)) + }; + items.push(canonical_tool_render_item( + id, msg_idx, - leading_blank_lines, - block_idx: Some(block_idx), - boundary_kind: LiveRowBoundaryKind::AssistantTool, - commit_ready: tool_call_commit_ready(tool), - item: AssistantRenderItem::CanonicalTool { msg_idx, block_idx }, - }); + block_idx, + leading_blank_lines_between(previous_kind, current_kind), + commit_ready, + )); previous_kind = Some(current_kind); } MessageBlock::Welcome(_) @@ -679,6 +693,91 @@ fn assistant_render_items_from_message( items } +fn notice_render_item( + notice: &NoticeBlock, + msg_idx: usize, + block_idx: usize, + leading_blank_lines: usize, + commit_ready: bool, +) -> AssistantRenderItemSpec { + AssistantRenderItemSpec { + ids: vec![HistoryOutputId::Block(notice.id)], + msg_idx, + leading_blank_lines, + block_idx: Some(block_idx), + boundary_kind: LiveRowBoundaryKind::AssistantNotice, + commit_ready, + item: AssistantRenderItem::Notice(NoticeBlock { + id: notice.id, + severity: notice.severity, + text: TextBlock::from_complete(¬ice.text.text) + .with_trailing_spacing(notice.text.trailing_spacing), + dedup_key: notice.dedup_key.clone(), + }), + } +} + +fn canonical_tool_render_item( + id: HistoryOutputId, + msg_idx: usize, + block_idx: usize, + leading_blank_lines: usize, + commit_ready: bool, +) -> AssistantRenderItemSpec { + AssistantRenderItemSpec { + ids: vec![id], + msg_idx, + leading_blank_lines, + block_idx: Some(block_idx), + boundary_kind: LiveRowBoundaryKind::AssistantTool, + commit_ready, + item: AssistantRenderItem::CanonicalTool { msg_idx, block_idx }, + } +} + +/// A frozen launch keeps its creation position; live controls follow current output. +fn append_detached_tool_interactions( + app: &mut App, + width: u16, + current_mode_id: Option<&str>, + rows: &mut Vec>, + boundaries: &mut Vec, +) { + let interactions: Vec<_> = app + .pending_interaction_ids + .iter() + .filter_map(|id| { + let (mi, bi) = app.lookup_tool_call(id)?; + match &app.transcript.messages[mi].blocks[bi] { + MessageBlock::ToolCall(tool) if tool.has_frozen_launch() => { + Some((id.clone(), mi, bi)) + } + _ => None, + } + }) + .collect(); + let spinner = SpinnerState::for_app(app); + for (id, msg_idx, block_idx) in interactions { + boundaries.push(LiveRowBoundary { + ids: vec![HistoryOutputId::ToolInteraction(id)], + msg_idx, + block_idx: Some(block_idx), + kind: LiveRowBoundaryKind::AssistantTool, + start_row: rows.len(), + commit_ready: false, + }); + rows.push(Line::default()); + rows.extend(render_canonical_tool_rows( + app, + msg_idx, + block_idx, + message_render_context(current_mode_id, width), + spinner, + true, + )); + } +} + fn btw_render_item( exchange: &BtwExchangeBlock, msg_idx: usize, @@ -794,11 +893,15 @@ fn next_visible_assistant_text(message: &ChatMessage, block_idx: usize) -> Optio match block { MessageBlock::Text(text) if text.text.is_empty() => {} MessageBlock::Text(text) => return Some(text), - MessageBlock::ToolCall(tool) if tool.hidden_unless_focused_interaction() => {} + MessageBlock::ToolCall(tool) | MessageBlock::ToolResult { tool, .. } + if tool.hidden_unless_focused_interaction() => {} MessageBlock::Welcome(_) | MessageBlock::ImageAttachment(_) | MessageBlock::UserDialog(_) => {} - MessageBlock::Notice(_) | MessageBlock::BtwExchange(_) | MessageBlock::ToolCall(_) => { + MessageBlock::Notice(_) + | MessageBlock::BtwExchange(_) + | MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } => { return None; } } @@ -810,11 +913,14 @@ fn last_visible_assistant_block_idx(message: &ChatMessage) -> Option { message.blocks.iter().enumerate().rev().find_map(|(block_idx, block)| match block { MessageBlock::Text(text) if !text.text.is_empty() => Some(block_idx), MessageBlock::Notice(_) | MessageBlock::BtwExchange(_) => Some(block_idx), - MessageBlock::ToolCall(tool) if !tool.hidden_unless_focused_interaction() => { + MessageBlock::ToolCall(tool) | MessageBlock::ToolResult { tool, .. } + if !tool.hidden_unless_focused_interaction() => + { Some(block_idx) } MessageBlock::Text(_) | MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } | MessageBlock::Welcome(_) | MessageBlock::ImageAttachment(_) | MessageBlock::UserDialog(_) => None, @@ -822,10 +928,11 @@ fn last_visible_assistant_block_idx(message: &ChatMessage) -> Option { } fn tool_call_commit_ready(tool: &ToolCallInfo) -> bool { - tool.status.is_terminal() - && tool.pending_permission.is_none() - && tool.pending_question.is_none() - && tool.terminal_id.is_none() + let display = tool.display(); + (tool.status.is_terminal() || tool.has_frozen_launch()) + && display.pending_permission.is_none() + && display.pending_question.is_none() + && display.terminal_id.is_none() } fn flush_pending_text_run( @@ -976,6 +1083,7 @@ fn render_assistant_rows(mut request: AssistantRowsRequest<'_>) -> RenderedMessa block_idx, message_render_context(request.current_mode_id, request.width), request.spinner, + false, ), ); } @@ -1294,9 +1402,10 @@ fn render_canonical_tool_rows( block_idx: usize, render_context: MessageRenderContext<'_>, spinner: SpinnerState, + interaction: bool, ) -> Vec> { let show_duration = app.config.show_turn_duration_effective(); - let Some(MessageBlock::ToolCall(tc)) = app + let Some(block) = app .transcript .messages .get_mut(msg_idx) @@ -1304,13 +1413,19 @@ fn render_canonical_tool_rows( else { return Vec::new(); }; + let tc = match block { + MessageBlock::ToolCall(tc) if interaction => tc.as_mut(), + MessageBlock::ToolCall(tc) => tc.display_mut(), + MessageBlock::ToolResult { tool, .. } => tool.as_mut(), + _ => return Vec::new(), + }; if tc.hidden_unless_focused_interaction() { return Vec::new(); } let mut rows = Vec::new(); tool_call::render_tool_call_cached( - tc.as_mut(), + tc, render_context.tool_render_context, render_context.width, spinner, @@ -1335,6 +1450,9 @@ fn render_canonical_tool_rows( Style::default().fg(theme::DIM), ))); } + if interaction { + tc.cache.evict_cached_render(); + } wrap_lines_to_physical_rows(&rows, render_context.width) } @@ -1580,6 +1698,7 @@ mod tests { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; if focused_permission { @@ -1835,7 +1954,7 @@ mod tests { } #[test] - fn detached_web_rows_remain_mutable_between_turns_until_the_result_arrives() { + fn detached_web_launch_is_stable_and_final_output_gets_its_own_rows() { let mut app = App::test_default(); let mut block = tool_call_block_with_status_interaction( "web-1", @@ -1850,11 +1969,15 @@ mod tests { tool.title = "Deferred web fetch".to_owned(); tool.sdk_tool_name = "WebFetch".to_owned(); app.transcript.messages.push(assistant_blocks_message(vec![block])); + app.index_tool_call("web-1".to_owned(), 0, 0); + app.sync_tool_call_history("web-1"); app.status = AppStatus::Ready; let serialized = serialize_all_rows_with_boundaries(&mut app, 120); - let mutable = line_texts(&serialized.rows()[serialized.stable_row_count()..]); - assert!(mutable.iter().any(|line| line.contains("Deferred web fetch"))); + assert_eq!(serialized.stable_row_count(), serialized.rows().len()); + let launch = line_texts(serialized.rows()); + assert!(launch.iter().any(|line| line.contains("Deferred web fetch") + && line.contains(crate::ui::theme::ICON_DETACHED))); let MessageBlock::ToolCall(tool) = &mut app.transcript.messages[0].blocks[0] else { unreachable!("original web call") @@ -1862,11 +1985,16 @@ mod tests { tool.status = model::ToolCallStatus::Completed; tool.content = vec![model::ToolCallContent::from("Actual web output")]; tool.invalidate_render_cache(); + app.sync_tool_call_history("web-1"); let serialized = serialize_all_rows_with_boundaries(&mut app, 120); assert_eq!(serialized.stable_row_count(), serialized.rows().len()); - assert!( - line_texts(serialized.rows()).iter().any(|line| line.contains("Actual web output")) - ); + let rows = line_texts(serialized.rows()); + let cards: Vec<_> = + rows.iter().filter(|line| line.contains("Deferred web fetch")).collect(); + assert_eq!(cards.len(), 2); + assert!(cards[0].contains(crate::ui::theme::ICON_DETACHED)); + assert!(cards[1].contains(crate::ui::theme::ICON_COMPLETED)); + assert!(rows.iter().any(|line| line.contains("Actual web output"))); } #[test] @@ -2239,9 +2367,9 @@ mod tests { Some(model::RuntimeSessionState::RequiresAction); assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); app.session_runtime.runtime_session_state = Some(model::RuntimeSessionState::Running); - app.turn.pending_interaction_ids.push("permission".into()); + app.pending_interaction_ids.push("permission".into()); assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request"]); - app.turn.pending_interaction_ids.clear(); + app.pending_interaction_ids.clear(); assert_eq!(line_texts(&serialize_live_rows(&mut app, 80)), ["User", "request", "Claude"]); let label_id = HistoryOutputId::AssistantLabel(app.transcript.messages[1].id); let excluded = BTreeSet::from([label_id]); @@ -2326,9 +2454,9 @@ mod tests { assert_eq!(activity.len(), 1); assert!(line_text(&activity[0]).contains(&verb)); assert_eq!(line_texts(&serialize_live_rows(&mut app, 120)), ["Claude", "◆ Thinking…"]); - app.turn.pending_interaction_ids.push("permission".into()); + app.pending_interaction_ids.push("permission".into()); assert!(serialize_live_rows(&mut app, 120).is_empty()); - app.turn.pending_interaction_ids.clear(); + app.pending_interaction_ids.clear(); app.turn.cancel_requested = true; assert_eq!(line_texts(&serialize_live_rows(&mut app, 120)), ["Claude"]); app.turn.cancel_requested = false; diff --git a/src/ui/input.rs b/src/ui/input.rs index 9ab625ee..ed15c9ff 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -367,7 +367,7 @@ mod tests { fn visual_line_count_hides_prompt_suggestion_hint_when_input_lacks_focus() { let mut app = App::test_default(); app.session_runtime.prompt_suggestion = Some("Write tests for the retry flow".to_owned()); - app.turn.pending_interaction_ids.push("perm-1".to_owned()); + app.pending_interaction_ids.push("perm-1".to_owned()); app.claim_focus_target(FocusTarget::Permission); assert_eq!(visual_line_count(&mut app, 80), 1 + INPUT_VERTICAL_PAD * 2); } diff --git a/src/ui/input_rows.rs b/src/ui/input_rows.rs index 17c636da..9fe5e883 100644 --- a/src/ui/input_rows.rs +++ b/src/ui/input_rows.rs @@ -428,7 +428,7 @@ mod tests { fn prompt_suggestion_hint_requires_input_focus() { let mut app = App::test_default(); app.session_runtime.prompt_suggestion = Some("Write tests".to_owned()); - app.turn.pending_interaction_ids.push("perm-1".to_owned()); + app.pending_interaction_ids.push("perm-1".to_owned()); app.claim_focus_target(FocusTarget::Permission); let rows = build_composer_hint_rows(&app); diff --git a/src/ui/message_rows.rs b/src/ui/message_rows.rs index 25ac09f3..e34cb57f 100644 --- a/src/ui/message_rows.rs +++ b/src/ui/message_rows.rs @@ -115,6 +115,7 @@ fn append_system_blocks(msg: &mut ChatMessage, width: u16, rows: &mut MessageRow rows.push_blank(); } MessageBlock::ToolCall(_) + | MessageBlock::ToolResult { .. } | MessageBlock::Welcome(_) | MessageBlock::ImageAttachment(_) => {} } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 4facb521..51657751 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -21,6 +21,7 @@ pub const USER_MSG_BG: Color = Color::Rgb(40, 44, 52); // Tool status icons pub const ICON_COMPLETED: &str = "\u{2713}"; +pub const ICON_DETACHED: &str = "\u{2197}"; pub const ICON_FAILED: &str = "\u{2717}"; // Status colors diff --git a/src/ui/tool_call/interactions.rs b/src/ui/tool_call/interactions.rs index 8f4b554b..677e4538 100644 --- a/src/ui/tool_call/interactions.rs +++ b/src/ui/tool_call/interactions.rs @@ -483,6 +483,7 @@ mod tests { cache: crate::app::BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } diff --git a/src/ui/tool_call/mod.rs b/src/ui/tool_call/mod.rs index 52fd4817..175dfc5e 100644 --- a/src/ui/tool_call/mod.rs +++ b/src/ui/tool_call/mod.rs @@ -62,7 +62,8 @@ pub fn status_icon( ) -> (&'static str, Color) { match status { model::ToolCallStatus::Pending => ("\u{25CB}", theme::RUST_ORANGE), - model::ToolCallStatus::InProgress | model::ToolCallStatus::Detached => { + model::ToolCallStatus::Detached => (theme::ICON_DETACHED, Color::LightBlue), + model::ToolCallStatus::InProgress => { let s = spinner.icon(); (s, theme::RUST_ORANGE) } @@ -394,6 +395,7 @@ pub(super) mod test_support { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; if let Some(content) = content { tc.content = vec![model::ToolCallContent::from(content)]; diff --git a/src/ui/tool_call/tests.rs b/src/ui/tool_call/tests.rs index c7516364..939ce07e 100644 --- a/src/ui/tool_call/tests.rs +++ b/src/ui/tool_call/tests.rs @@ -24,6 +24,7 @@ fn test_tool_call(id: &str, sdk_tool_name: &str, status: model::ToolCallStatus) cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, } } @@ -929,6 +930,7 @@ fn bash_title_does_not_wrap_for_long_title() { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; let top = standard::render_tool_call_title( @@ -1628,6 +1630,7 @@ fn content_summary_only_extracts_tool_use_error_for_failed_execute() { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; assert_eq!(content_summary(&tc), "done"); } @@ -1654,6 +1657,7 @@ fn content_summary_extracts_tool_use_error_for_failed_execute() { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; assert_eq!(content_summary(&tc), "bad"); } @@ -1692,6 +1696,7 @@ fn content_summary_uses_first_terminal_line_for_failed_execute() { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; assert_eq!(content_summary(&tc), "Exit code 1"); } @@ -1740,6 +1745,7 @@ fn render_execute_content_keeps_tail_output() { cache: BlockCache::default(), pending_permission: None, pending_question: None, + history: crate::app::ToolCallHistory::Live, }; let lines = execute::render_execute_content(&tc); diff --git a/tests/fixtures/fake-bridge.js b/tests/fixtures/fake-bridge.js index dc6274d4..8b587b7d 100644 --- a/tests/fixtures/fake-bridge.js +++ b/tests/fixtures/fake-bridge.js @@ -111,6 +111,98 @@ const model = { function streamReply(messageUuid) { send({ event: 'user_message_started', session_id: SESSION, message_uuid: messageUuid, source: 'command_lifecycle' }); replyNumber++; + if (SCENARIO === 'background-replies') { + const update = update => send({ event: 'session_update', session_id: SESSION, update }); + const text = text => update({ type: 'agent_message_chunk', content: { type: 'text', text } }); + if (replyNumber === 1) { + for (let i = 1; i <= 3; i++) update({ type: 'tool_call', tool_call: { + tool_call_id: `completion-${i}`, title: `LAUNCH_${i}`, kind: 'execute', status: 'detached', + content: [], raw_input: { command: `LAUNCH_${i}` }, locations: [], meta: { claudeCode: { toolName: 'Bash' } }, + } }); + text('Foreground reply is complete.'); + send({ event: 'turn_complete', session_id: SESSION }); + let previous = ''; + // Independent completions continue after the foreground turn has ended. + setInterval(() => { + const step = fs.existsSync(RELEASE_FILE) ? fs.readFileSync(RELEASE_FILE, 'utf8') : ''; + if (step === previous) return; + previous = step; + const match = /^(result|prefix|finish)-([123])$/.exec(step); + if (!match) return; + const [, action, number] = match; + if (action === 'result') { + for (let i = 0; i < 2; i++) update({ type: 'tool_call_update', tool_call_update: { + tool_call_id: `completion-${number}`, fields: { status: 'completed', title: `RESULT_${number}`, raw_output: `OUTPUT_${number}` }, + } }); + } else if (action === 'prefix') { + update({ type: 'agent_response_started' }); + text(number === '1' ? 'The' : 'Short task 2 (`'); + } else { + text(number === '1' ? ' first background task finished completely.' : 'task-two`) finished with exit code 0.'); + send({ event: 'turn_complete', session_id: SESSION }); + } + record({ type: 'barrier', name: step }); + }, INTERVAL_MS); + } else if (replyNumber === 2) { + update({ type: 'agent_response_started' }); + text('Normal conversation continues completely.'); + send({ event: 'turn_complete', session_id: SESSION }); + } else { + active = { timer: setInterval(() => {}, 1000) }; + update({ type: 'agent_activity_update', phase: 'thinking' }); + record({ type: 'barrier', name: 'cancellable-turn-started' }); + } + return; + } + if (SCENARIO.startsWith('background-')) { + const update = update => send({ event: 'session_update', session_id: SESSION, update }); + update({ type: 'message_metadata', role: 'user', timestamp: '2026-10-06T10:41:54.067Z', source_message_uuid: messageUuid }); + const fields = fields => update({ type: 'tool_call_update', tool_call_update: { tool_call_id: 'background-primary', fields } }); + const tool = (id, command, status) => { + const agent = id === 'background-primary' && SCENARIO.includes('agent'); + update({ type: 'tool_call', tool_call: { + tool_call_id: id, title: command, kind: agent ? 'other' : 'execute', status, content: [], + raw_input: agent ? { description: command, prompt: 'Work independently' } : { command }, + raw_output: null, locations: [], meta: { claudeCode: { toolName: agent ? 'Agent' : 'Bash' } }, + } }); + }; + if (replyNumber === 1 && SCENARIO !== 'background-resume') { + tool('background-primary', 'PRIMARY_TASK', 'in_progress'); + if (SCENARIO.endsWith('-initial')) fields({ status: 'detached' }); + } + let previous = fs.existsSync(RELEASE_FILE) ? fs.readFileSync(RELEASE_FILE, 'utf8') : ''; + const timer = setInterval(() => { + const step = fs.existsSync(RELEASE_FILE) ? fs.readFileSync(RELEASE_FILE, 'utf8') : ''; + if (step === previous) return; + previous = step; + if (step === 'detach') fields({ status: 'detached' }); + if (step === 'followers') { + for (let i = 1; i <= LINES; i++) tool(`follower-${i}`, `FOLLOWUP_${String(i).padStart(3, '0')}`, 'completed'); + record({ type: 'barrier', name: 'followers-created' }); + } + if (step === 'progress') { + fields({ status: 'detached', title: 'MUTATED_LAUNCH', raw_output: 'BACKGROUND_PROGRESS_ONLY' }); + tool('progress-fence', 'PROGRESS_FENCE', 'completed'); + } + if (step === 'permission') { + send({ event: 'permission_request', session_id: SESSION, request: { + tool_call: { tool_call_id: 'background-primary', title: 'PRIMARY_TASK', kind: 'execute', status: 'detached', content: [], locations: [], meta: { claudeCode: { toolName: 'Bash' } } }, + options: [ + { option_id: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { option_id: 'deny-once', name: 'Deny once', kind: 'reject_once' }, + ], display: null, mcp_server: null, + } }); + } + if (['completed', 'failed', 'killed'].includes(step)) { + for (let i = 0; i < 2; i++) fields({ status: step, title: 'PRIMARY_RESULT_TASK', raw_output: 'PRIMARY_FINAL_OUTPUT', content: [{ type: 'content', content: { type: 'text', text: 'PRIMARY_FINAL_OUTPUT' } }] }); + tool('result-fence', 'RESULT_FENCE', 'completed'); + } + if (step === 'turn-end') finish({ event: 'turn_complete', session_id: SESSION }); + }, INTERVAL_MS); + active = { timer }; + record({ type: 'barrier', name: `background-turn-${replyNumber}-held` }); + return; + } if (!activityScenario || replyNumber !== 1) send({ event: 'session_update', session_id: SESSION, @@ -263,10 +355,14 @@ readline mode: null, fast_mode_state: 'off', fast_mode_disabled_reason: null, - history_updates: null, + history_updates: SCENARIO === 'background-resume' ? [ + { type: 'user_message_chunk', content: { type: 'text', text: 'Resumed background launch' }, source_message_uuid: 'saved-user' }, + { type: 'tool_call', tool_call: { tool_call_id: 'background-primary', title: 'PRIMARY_TASK', kind: 'execute', status: 'in_progress', content: [], raw_input: { command: 'PRIMARY_TASK' }, locations: [], meta: { claudeCode: { toolName: 'Bash' } } } }, + { type: 'tool_call_update', tool_call_update: { tool_call_id: 'background-primary', fields: { status: 'detached' } } }, + ] : null, restored_input: null, }); - if (SCENARIO.startsWith('resize-')) send({ event: 'status_snapshot', session_id: SESSION, account: { subscription_type: 'Fixture subscription' } }); + if (SCENARIO.startsWith('resize-') || SCENARIO.startsWith('background-')) send({ event: 'status_snapshot', session_id: SESSION, account: { subscription_type: 'Fixture subscription' } }); if (SCENARIO === 'disconnect-during-auth' && message.command === 'create_session') { const timer = setInterval(() => { if (fs.existsSync(RELEASE_FILE)) { @@ -321,6 +417,10 @@ readline break; case 'permission_response': case 'question_response': + if (SCENARIO.startsWith('background-') && message.tool_call_id === 'background-primary') { + record({ type: 'barrier', name: 'background-permission-answered' }); + break; + } if (!active || message.tool_call_id !== 'fixture-tool') throw new Error('Unexpected interaction response'); send({ event: 'session_update', session_id: SESSION, update: { type: 'tool_call_update', tool_call_update: { diff --git a/tests/integration/permissions.rs b/tests/integration/permissions.rs index b4a1a5ca..b627e03c 100644 --- a/tests/integration/permissions.rs +++ b/tests/integration/permissions.rs @@ -63,8 +63,8 @@ async fn permission_request_attaches_to_tool_call() { let mut app = test_app(); let _rx = setup_permission(&mut app, "tc-perm-1", allow_deny_options()); - assert_eq!(app.turn.pending_interaction_ids.len(), 1); - assert_eq!(app.turn.pending_interaction_ids[0], "tc-perm-1"); + assert_eq!(app.pending_interaction_ids.len(), 1); + assert_eq!(app.pending_interaction_ids[0], "tc-perm-1"); // The tool call should have a pending_permission let (mi, bi) = app.lookup_tool_call("tc-perm-1").expect("tc-perm-1 indexed"); @@ -94,7 +94,7 @@ async fn permission_for_unknown_tool_call_auto_rejects() { send_client_event(&mut app, permission_request(request, response_tx)); // Should NOT be in pending queue - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); // The response should have been sent (auto-reject with last option = "deny") let response = response_rx.try_recv(); @@ -115,9 +115,9 @@ async fn multiple_permissions_queue_in_order() { let _rx1 = setup_permission(&mut app, "tc-q1", allow_deny_options()); let _rx2 = setup_permission(&mut app, "tc-q2", allow_deny_options()); - assert_eq!(app.turn.pending_interaction_ids.len(), 2); - assert_eq!(app.turn.pending_interaction_ids[0], "tc-q1"); - assert_eq!(app.turn.pending_interaction_ids[1], "tc-q2"); + assert_eq!(app.pending_interaction_ids.len(), 2); + assert_eq!(app.pending_interaction_ids[0], "tc-q1"); + assert_eq!(app.pending_interaction_ids[1], "tc-q2"); // First should be focused, second should not let (mi1, bi1) = app.lookup_tool_call("tc-q1").expect("tc-q1 indexed"); @@ -145,7 +145,7 @@ async fn duplicate_permission_request_is_rejected_without_duplicate_queue_entry( ); send_client_event(&mut app, permission_request(request, response_tx)); - assert_eq!(app.turn.pending_interaction_ids, vec!["tc-dup"]); + assert_eq!(app.pending_interaction_ids, vec!["tc-dup"]); assert!(matches!(first_rx.try_recv(), Err(tokio::sync::oneshot::error::TryRecvError::Empty))); let resp = duplicate_rx.try_recv().expect("duplicate permission should be auto-rejected"); @@ -170,7 +170,7 @@ async fn turn_complete_resets_transient_state() { assert_eq!(app.files_accessed, 0, "files_accessed should reset"); // spinner_frame is a UI detail, not reset by TurnComplete (it's driven by tick) // pending_interaction_ids should be empty (no permissions were pending) - assert!(app.turn.pending_interaction_ids.is_empty()); + assert!(app.pending_interaction_ids.is_empty()); } #[tokio::test] diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 8505b110..d586d53a 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -694,6 +694,275 @@ fn auth_child_owns_stdin_and_output_and_returns_a_resized_usable_terminal() { } } +#[test] +fn background_permission_remains_interactive_after_launch_enters_history() { + for (keys, option, outcome) in + [(b"\r".as_slice(), "allow-once", "completed"), (b"\x1b".as_slice(), "deny-once", "killed")] + { + let mut test = TerminalTest::start("background-initial", 45); + test.submit("BACKGROUND_PERMISSION", "BACKGROUND_PERMISSION"); + test.wait_journal("background-turn-1-held"); + test.wait_screen("PRIMARY_TASK"); + test.signal("followers"); + test.wait_until("background launch and followers enter history", |test| { + let rows = test.transcript_rows(); + rows.iter().any(|row| row.contains("FOLLOWUP_045")) + && rows.iter().filter(|row| row.contains("FOLLOWUP_")).count() == 45 + }); + test.signal("permission"); + test.wait_screen_with("Allow once", &["Deny once"]); + test.resize_keeping(25, 61, &["Allow once", "Deny once"]); + let screen = test.screen(); + let rows: Vec<_> = screen.lines().collect(); + let tool = + rows.iter().rposition(|row| row.contains("PRIMARY_TASK")).expect("permission tool row"); + assert!( + rows.iter() + .skip(tool) + .take(9) + .any(|row| row.contains("Allow once") && row.contains("Deny once")) + ); + assert!(test.commands("permission_response").is_empty()); + test.signal("turn-end"); + test.wait_screen_with("[READY]", &["Allow once", "Deny once"]); + test.resize_keeping(TALL_ROWS, 120, &["Allow once", "Deny once"]); + test.send(keys); + test.wait_journal("background-permission-answered"); + test.wait_until( + "permission controls disappear without completing the background task", + |test| { + !test.screen().contains("Allow once") + && !test.transcript_rows().iter().any(|row| row.contains("PRIMARY_RESULT_TASK")) + }, + ); + let responses = test.commands("permission_response"); + assert_eq!(responses.len(), 1); + assert_eq!(responses[0]["tool_call_id"], "background-primary"); + assert_eq!(responses[0]["outcome"]["option_id"], option); + test.submit("continue after background permission", "continue after background permission"); + test.wait_journal("background-turn-2-held"); + test.signal(outcome); + test.wait_screen("RESULT_FENCE"); + test.resize_and_wait_for_draw(TALL_ROWS, COLS); + let rows = test.transcript_rows(); + assert_eq!(rows.iter().filter(|row| row.contains("PRIMARY_TASK")).count(), 1); + assert_eq!(rows.iter().filter(|row| row.contains("PRIMARY_RESULT_TASK")).count(), 1); + test.signal("turn-end"); + test.wait_screen("[READY]"); + test.shutdown(); + } +} + +#[test] +fn background_result_labels_follow_the_visible_speaker_through_replay_and_cancellation() { + let mut test = TerminalTest::start("background-replies", 3); + test.submit("BACKGROUND_LABELS", "BACKGROUND_LABELS"); + test.wait_turn_finished("Foreground reply is complete."); + for number in 1..=2 { + test.signal(&format!("result-{number}")); + test.wait_journal(&format!("result-{number}")); + test.wait_screen(&format!("OUTPUT_{number}")); + } + let assert_group = |test: &TerminalTest| { + let rows = test.transcript_rows(); + let first = rows.iter().position(|row| row.trim() == "Claude").expect("speaker label"); + let last = rows.iter().position(|row| row.contains("OUTPUT_2")).expect("second result"); + assert_eq!(rows[first..=last].iter().filter(|row| row.trim() == "Claude").count(), 1); + let positions: Vec<_> = + ["LAUNCH_1", "LAUNCH_2", "LAUNCH_3", "Foreground reply", "RESULT_1", "RESULT_2"] + .into_iter() + .map(|needle| { + rows.iter().position(|row| row.contains(needle)).expect("ordered content") + }) + .collect(); + assert!(positions.windows(2).all(|pair| pair[0] < pair[1])); + for number in 1..=2 { + assert_eq!( + rows.iter().filter(|row| row.contains(&format!("RESULT_{number}"))).count(), + 1 + ); + } + }; + assert_group(&test); + test.resize_and_wait_for_draw(TALL_ROWS, COLS); + assert_group(&test); + test.submit("NORMAL_FOLLOWUP", "NORMAL_FOLLOWUP"); + test.wait_turn_finished("Normal conversation continues completely."); + test.submit("CANCEL_EMPTY_REPLY", "CANCEL_EMPTY_REPLY"); + test.wait_journal("cancellable-turn-started"); + test.wait_screen("Thinking…"); + test.send(b"\x1b"); + test.wait_until("cancelled turn settles", |test| { + test.commands("cancel_turn").len() == 1 && test.screen().contains("[READY]") + }); + test.signal("result-3"); + test.wait_screen("OUTPUT_3"); + test.resize_and_wait_for_draw(SHORT_ROWS, COLS); + let rows = test.transcript_rows(); + let normal = + rows.iter().position(|row| row.contains("NORMAL_FOLLOWUP")).expect("normal user message"); + let cancelled = rows + .iter() + .position(|row| row.contains("CANCEL_EMPTY_REPLY")) + .expect("cancelled user message"); + let result = rows.iter().position(|row| row.contains("RESULT_3")).expect("later result"); + assert_eq!(rows[normal..cancelled].iter().filter(|row| row.trim() == "Claude").count(), 1); + assert_eq!(rows[cancelled..=result].iter().filter(|row| row.trim() == "Claude").count(), 1); + test.assert_prompts(&["BACKGROUND_LABELS", "NORMAL_FOLLOWUP", "CANCEL_EMPTY_REPLY"]); + test.shutdown(); +} + +#[test] +fn background_completion_replies_keep_streamed_tails_mutable_and_preserve_normal_turns() { + let mut test = TerminalTest::start("background-replies", 3); + test.submit("BACKGROUND_STREAM", "BACKGROUND_STREAM"); + test.wait_turn_finished("Foreground reply is complete."); + test.paste("NORMAL_AFTER_COMPLETIONS"); + test.wait_screen("NORMAL_AFTER_COMPLETIONS"); + for (number, prefix, full) in [ + (1, "The", "The first background task finished completely."), + (2, "Short task 2 (`", "Short task 2 (task-two) finished with exit code 0."), + ] { + test.signal(&format!("result-{number}")); + test.wait_screen(&format!("OUTPUT_{number}")); + let log_offset = + std::fs::read(test.temp.path().join("runtime.log")).expect("runtime log").len(); + test.signal(&format!("prefix-{number}")); + test.wait_journal(&format!("prefix-{number}")); + test.wait_screen(prefix); + test.wait_until("unfinished reply remains in the mutable region", |test| { + test.runtime_records_since(log_offset).iter().any(|record| { + record["event_name"] == "inline_chat_draw_summary" + && record["status"] == "Running" + && record["first_mutable_boundary_kind"] == "Some(AssistantText)" + }) + }); + test.resize_keeping( + if number == 1 { TALL_ROWS } else { SHORT_ROWS }, + COLS, + &[prefix, "NORMAL_AFTER_COMPLETIONS"], + ); + test.signal(&format!("finish-{number}")); + test.wait_journal(&format!("finish-{number}")); + test.wait_turn_finished(full); + let rows = test.transcript_rows(); + assert_eq!(rows.iter().filter(|row| row.trim() == full).count(), 1); + assert!(!rows.iter().any(|row| row.trim() == prefix)); + } + test.submit_draft(); + test.wait_turn_finished("Normal conversation continues completely."); + test.resize_and_wait_for_draw(TALL_ROWS, COLS); + let rows = test.transcript_rows(); + for full in [ + "Foreground reply is complete.", + "The first background task finished completely.", + "Short task 2 (task-two) finished with exit code 0.", + "Normal conversation continues completely.", + ] { + assert_eq!(rows.iter().filter(|row| row.trim() == full).count(), 1); + } + test.assert_prompts(&["BACKGROUND_STREAM", "NORMAL_AFTER_COMPLETIONS"]); + test.shutdown(); +} + +#[test] +fn background_tasks_flush_in_creation_order_and_append_results_once() { + for (scenario, outcome) in [ + ("background-initial", "completed"), + ("background-later", "failed"), + ("background-later", "killed"), + ("background-agent-initial", "completed"), + ("background-agent-later", "failed"), + ("background-resume", "completed"), + ] { + let mut test = if scenario == "background-resume" { + let mut test = TerminalTest::start_with_options( + scenario, + 45, + None, + &["--resume", "f96f66ae-b5d4-4bc2-b5a3-08336cf4e850"], + false, + ); + test.wait_screen("PRIMARY_TASK"); + assert_eq!(test.commands("resume_session").len(), 1); + test + } else { + TerminalTest::start(scenario, 45) + }; + test.submit("start background workflow", "start background workflow"); + test.wait_journal("background-turn-1-held"); + if scenario != "background-resume" { + test.wait_screen("PRIMARY_TASK"); + } + if scenario.ends_with("-later") { + test.signal("detach"); + test.wait_until("primary tool becomes detached", |test| { + test.transcript_rows() + .iter() + .any(|row| row.contains("PRIMARY_TASK") && row.contains('↗')) + }); + } + test.signal("followers"); + test.wait_journal("followers-created"); + test.wait_until("completed followers reach history while primary is running", |test| { + let rows = test.transcript_rows(); + rows.iter().any(|row| row.contains("FOLLOWUP_045")) + && rows.iter().any(|row| row.contains("PRIMARY_TASK") && row.contains('↗')) + && (1..=45) + .all(|i| rows.iter().any(|row| row.contains(&format!("FOLLOWUP_{i:03}")))) + }); + let rows = test.transcript_rows(); + let position = |needle: &str| { + rows.iter().position(|row| row.contains(needle)).expect("ordered history entry") + }; + assert!(position("PRIMARY_TASK") < position("FOLLOWUP_001")); + let follower_positions: Vec<_> = + (1..=45).map(|i| position(&format!("FOLLOWUP_{i:03}"))).collect(); + assert!(follower_positions.windows(2).all(|pair| pair[0] < pair[1])); + assert!(!rows.iter().any(|row| row.contains("PRIMARY_FINAL_OUTPUT"))); + + test.signal("progress"); + test.wait_until("background progress applied without rewriting launch", |test| { + test.transcript_rows().iter().any(|row| row.contains("PROGRESS_FENCE")) + }); + assert!( + !test + .transcript_rows() + .iter() + .any(|row| row.contains("MUTATED_LAUNCH") + || row.contains("BACKGROUND_PROGRESS_ONLY")) + ); + test.signal("turn-end"); + test.wait_until("foreground turn completes independently", |test| { + test.screen().contains("[READY]") + }); + test.submit("another foreground turn", "another foreground turn"); + test.wait_journal("background-turn-2-held"); + test.signal(outcome); + test.wait_until("linked background result is rendered", |test| { + test.transcript_rows().iter().any(|row| row.contains("RESULT_FENCE")) + }); + let rows = test.transcript_rows(); + let result = + rows.iter().position(|row| row.contains("PRIMARY_RESULT_TASK")).expect("result card"); + assert!( + result + > rows.iter().position(|row| row.contains("FOLLOWUP_045")).expect("last follower") + ); + assert_eq!(rows.iter().filter(|row| row.contains("PRIMARY_TASK")).count(), 1); + assert_eq!(rows.iter().filter(|row| row.contains("PRIMARY_RESULT_TASK")).count(), 1); + assert!(rows[result + 1..].iter().take(4).any(|row| row.contains("PRIMARY_FINAL_OUTPUT"))); + assert!(rows[result].contains(if outcome == "completed" { '✓' } else { '✗' })); + test.resize_and_wait_for_draw(TALL_ROWS, COLS); + let replayed = test.transcript_rows(); + assert_eq!(replayed.iter().filter(|row| row.contains("PRIMARY_TASK")).count(), 1); + assert_eq!(replayed.iter().filter(|row| row.contains("PRIMARY_RESULT_TASK")).count(), 1); + test.signal("turn-end"); + test.wait_until("second turn finishes", |test| test.screen().contains("[READY]")); + test.shutdown(); + } +} + #[test] fn auth_spawn_failure_restores_terminal_ownership_and_input() { let mut test = TerminalTest::start_with_auth("stream", 3, Some("spawn-error")); From f061c0b63e5e246582a7d203e4c7a4eee80fcf39 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 16:21:11 +0200 Subject: [PATCH 5/8] perf(tui): cache one-line tool title rendering - project all tool titles before rendering, including long MCP titles - render shell titles literally and preserve full command and output data - reuse title content across animation, resize, and body updates - synchronize cache accounting after rendering and eviction - add regressions for title styling, cache reuse, and transcript replay --- docs/src/architecture.md | 2 + src/app/state/block_cache.rs | 45 +++++++- src/ui/inline_chat_rows.rs | 81 +++++++++++++- src/ui/tool_call/mod.rs | 4 +- src/ui/tool_call/standard.rs | 8 +- src/ui/tool_call/tests.rs | 201 +++++++++++++++++++++++++++++++++++ 6 files changed, 332 insertions(+), 9 deletions(-) diff --git a/docs/src/architecture.md b/docs/src/architecture.md index ba212050..80cda0b4 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -75,6 +75,8 @@ Automatic updates uses the same catalog and writer for the User-only `updates.au ### Presentation and timing +Tool titles share a one-line display projection and a source-keyed inline cache in the existing block cache. Shell command titles render literally; other tool titles retain Markdown styling. Full titles and command inputs remain canonical data. Spinner/status chrome, metadata badges and width clipping stay live, while unchanged title content survives body updates and resize. Inline cache bytes participate in the existing render budget and eviction workflow. + | Concern | Owner | Location | | --- | --- | --- | | Normalizing SDK wall timestamps, result elapsed and API timing, tool and task elapsed metadata | Bridge | `agent-sdk/src/bridge/presentation_metadata.ts` | diff --git a/src/app/state/block_cache.rs b/src/app/state/block_cache.rs index 4a74e510..8c1c9277 100644 --- a/src/app/state/block_cache.rs +++ b/src/app/state/block_cache.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2025 Simon Peter Rothgang -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicU64, Ordering}; static CACHE_ACCESS_TICK: AtomicU64 = AtomicU64::new(1); @@ -10,8 +10,8 @@ pub(crate) fn next_cache_access_tick() -> u64 { CACHE_ACCESS_TICK.fetch_add(1, Ordering::Relaxed) } -/// Cached rendered lines for a block. Stores a version counter so the cache -/// is only recomputed when the block content actually changes. +/// Cached body lines and source-keyed inline content for a block. The version +/// tracks body changes; unchanged inline content survives body invalidation. /// /// Fields are private - use `invalidate()` to mark stale, `is_stale()` to check, /// `get()` to read cached lines, and `store()` to populate. @@ -31,6 +31,14 @@ pub struct BlockCache { wrapped_width: u16, wrapped_height_valid: bool, last_access_tick: Cell, + /// Source-keyed inline content survives body invalidation and width changes. + inline: RefCell>, +} + +struct InlineCache { + source: String, + literal: bool, + spans: Vec>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -51,6 +59,27 @@ impl BlockCache { self.last_access_tick.set(next_cache_access_tick()); } + /// Memoize inline content independently of animated chrome and body versions. + pub(crate) fn inline_spans( + &self, + source: &str, + literal: bool, + render: impl FnOnce(&str) -> Vec>, + ) -> Vec> { + if let Some(cached) = self.inline.borrow().as_ref() + && cached.source == source + && cached.literal == literal + { + self.touch(); + return cached.spans.clone(); + } + let spans = render(source); + *self.inline.borrow_mut() = + Some(InlineCache { source: source.to_owned(), literal, spans: spans.clone() }); + self.touch(); + spans + } + /// Bump the version to invalidate cached lines and height. pub fn invalidate(&mut self) { self.version += 1; @@ -150,7 +179,12 @@ impl BlockCache { #[must_use] pub fn cached_bytes(&self) -> usize { - self.cached_bytes + let inline_bytes = self.inline.borrow().as_ref().map_or(0, |cached| { + cached.spans.iter().fold(cached.source.len().saturating_add(1), |bytes, span| { + bytes.saturating_add(span.content.len()) + }) + }); + self.cached_bytes.saturating_add(inline_bytes) } #[must_use] @@ -159,7 +193,7 @@ impl BlockCache { } pub fn evict_cached_render(&mut self) -> usize { - let removed = self.cached_bytes; + let removed = self.cached_bytes(); if removed == 0 { return 0; } @@ -167,6 +201,7 @@ impl BlockCache { self.render_width = None; self.segments.clear(); self.cached_bytes = 0; + *self.inline.get_mut() = None; self.wrapped_height = 0; self.wrapped_width = 0; self.wrapped_height_valid = false; diff --git a/src/ui/inline_chat_rows.rs b/src/ui/inline_chat_rows.rs index d068f37c..0ed12cf9 100644 --- a/src/ui/inline_chat_rows.rs +++ b/src/ui/inline_chat_rows.rs @@ -1453,7 +1453,9 @@ fn render_canonical_tool_rows( if interaction { tc.cache.evict_cached_render(); } - wrap_lines_to_physical_rows(&rows, render_context.width) + let physical_rows = wrap_lines_to_physical_rows(&rows, render_context.width); + app.sync_render_cache_slot(msg_idx, block_idx); + physical_rows } pub(crate) fn assistant_role_label_line() -> Line<'static> { @@ -1656,6 +1658,83 @@ mod tests { block } + #[test] + fn live_tool_titles_keep_full_sources_and_obey_cache_budget_and_history_exclusion() { + let mut app = App::test_default(); + app.status = AppStatus::Ready; + let command = + format!("echo '**literal** _argument_'\n{}", " large command tail\n".repeat(500)); + let read_title = format!("**Read report**\n{}", "discarded description\n".repeat(500)); + let mcp_title = format!( + "**Search MCP documents** {}\n{}", + "long query ".repeat(500), + "```unsupported-title-language\nignored\n```\n".repeat(100) + ); + app.transcript.messages.push(assistant_blocks_message(vec![ + named_tool_call_block("shell-title", &command, "Bash"), + named_tool_call_block("read-title", &read_title, "Read"), + named_tool_call_block("mcp-title", &mcp_title, "mcp__docs__search"), + ])); + + let rendered = serialize_all_rows_with_boundaries(&mut app, 100); + for (id, expected) in + [("shell-title", "echo '**literal** _argument_'"), ("read-title", "Read report")] + { + let segment = rendered + .segments() + .iter() + .find(|segment| segment.ids.contains(&HistoryOutputId::ToolCall(id.to_owned()))) + .expect("tool has its own transcript segment"); + let rows = rendered.segment_rows(segment).unwrap(); + assert_eq!(rows.len(), 1, "title-only tool must stay on one row"); + assert!(line_text(&rows[0]).ends_with(expected)); + assert!(segment.commit_ready); + } + let bytes = app.enforce_render_cache_budget().total_before_bytes; + assert!( + bytes > 0 && bytes < command.len() + read_title.len() + mcp_title.len(), + "cache must contain title projections, not discarded multiline tails: {bytes}" + ); + let mcp_segment = rendered + .segments() + .iter() + .find(|segment| { + segment.ids.contains(&HistoryOutputId::ToolCall("mcp-title".to_owned())) + }) + .expect("MCP tool has its own segment"); + let mcp_rows = rendered.segment_rows(mcp_segment).unwrap(); + assert_eq!(mcp_rows.len(), 1); + assert!(line_text(&mcp_rows[0]).contains("Search MCP documents")); + assert!(line_text(&mcp_rows[0]).ends_with('…')); + let replay = serialize_all_rows_with_boundaries(&mut app, 100); + assert_eq!(replay.rows(), rendered.rows()); + assert_eq!(app.enforce_render_cache_budget().total_before_bytes, bytes); + + app.render_cache_budget.max_bytes = 0; + let stats = app.enforce_render_cache_budget(); + assert_eq!(stats.evicted_blocks, 3, "title-only tool caches must be evictable"); + assert_eq!(stats.evicted_bytes, bytes); + assert_eq!(stats.total_after_bytes, 0); + for (idx, source) in [(0, &command), (1, &read_title), (2, &mcp_title)] { + let MessageBlock::ToolCall(tool) = &app.transcript.messages[0].blocks[idx] else { + unreachable!("seeded tool block") + }; + assert_eq!(&tool.title, source); + assert_eq!(tool.cache.cached_bytes(), 0); + } + app.render_cache_budget.max_bytes = usize::MAX; + let resized = serialize_all_rows_with_boundaries(&mut app, 24); + assert!(resized.rows().iter().all(|row| row.width() <= 24)); + assert!(app.enforce_render_cache_budget().total_before_bytes > 0); + let restored = serialize_all_rows_with_boundaries(&mut app, 100); + assert_eq!(restored.rows(), rendered.rows()); + + let confirmed: BTreeSet<_> = + restored.segments().iter().flat_map(|segment| segment.ids.iter().cloned()).collect(); + let live = serialize_live_rows_with_boundaries_excluding(&mut app, 100, &confirmed); + assert!(live.rows().is_empty(), "confirmed tool titles must not reappear in live output"); + } + fn tool_call_block_with_interaction( id: &str, hidden: bool, diff --git a/src/ui/tool_call/mod.rs b/src/ui/tool_call/mod.rs index 175dfc5e..9e1c94c4 100644 --- a/src/ui/tool_call/mod.rs +++ b/src/ui/tool_call/mod.rs @@ -80,8 +80,8 @@ pub fn status_icon( /// Render a tool call with caching. Only re-renders when cache is stale. /// -/// The title is rendered live and the body is cached independently. Tool content -/// is capped by policy, while pending permission/question controls remain visible. +/// Title content and body are cached independently; title chrome stays live. +/// Tool content is capped by policy, while pending permission/question controls remain visible. pub fn render_tool_call_cached( tc: &mut ToolCallInfo, render_context: ToolCallRenderContext<'_>, diff --git a/src/ui/tool_call/standard.rs b/src/ui/tool_call/standard.rs index dfbfa90f..69326c04 100644 --- a/src/ui/tool_call/standard.rs +++ b/src/ui/tool_call/standard.rs @@ -74,7 +74,13 @@ pub(super) fn render_tool_call_title( } let display_title = tool_display_title(tc, render_context); - title_spans.extend(markdown_inline_spans(display_title.as_ref())); + // Titles have one source line. Shell text is literal command syntax, not Markdown. + // Keep the full title/command in the model; only this display projection is narrowed. + let title_line = display_title.lines().next().unwrap_or_default(); + let literal = tc.is_execute_tool(); + title_spans.extend(tc.cache.inline_spans(title_line, literal, |source| { + if literal { vec![Span::raw(source.to_owned())] } else { markdown_inline_spans(source) } + })); title_spans.extend(tool_output_badge_spans(tc)); Line::from(truncate_spans_to_width(title_spans, usize::from(width))) diff --git a/src/ui/tool_call/tests.rs b/src/ui/tool_call/tests.rs index 939ce07e..e921ba44 100644 --- a/src/ui/tool_call/tests.rs +++ b/src/ui/tool_call/tests.rs @@ -183,6 +183,207 @@ fn markdown_inline_spans_removes_markdown_syntax() { assert!(!rendered.contains('_')); } +#[test] +fn tool_titles_project_one_source_line_and_preserve_literal_shell_commands() { + for tool_name in ["Bash", "PowerShell", "Read", "Agent", "mcp__docs__search"] { + let first = if matches!(tool_name, "Bash" | "PowerShell") { + "echo '**literal** _name_ [arg](value)'" + } else { + "**Visible title**" + }; + let title = + format!("{first}\r\n```unsupported-title-language\n discarded title body\n```\n"); + let mut tc = + test_tool_call("title-projection", tool_name, model::ToolCallStatus::InProgress); + tc.title = title.clone(); + let input = if tc.is_execute_tool() { + tc.terminal_command = Some(title.clone()); + serde_json::json!({ "command": title }) + } else if tool_name == "Read" { + serde_json::json!({ "file_path": "report.md" }) + } else if tool_name == "Agent" { + serde_json::json!({ "prompt": "Inspect the report", "subagent_type": "general-purpose" }) + } else { + serde_json::json!({ "query": "Find report references" }) + }; + tc.set_raw_input(Some(input.clone())); + if tool_name == "mcp__docs__search" { + tc.content = vec![model::ToolCallContent::from("Independent MCP result output")]; + } + + let mut rows = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut rows, + ); + assert!(!rows.is_empty()); + let spans = &rows[0].spans; + let title_span = spans + .iter() + .find(|span| span.content.contains("literal") || span.content.contains("Visible")) + .expect("title content in the tool's title row"); + if matches!(tool_name, "Bash" | "PowerShell") { + assert_eq!(title_span.content.as_ref(), first); + } else { + assert_eq!(title_span.content.as_ref(), "Visible title"); + assert!(title_span.style.add_modifier.contains(ratatui::style::Modifier::BOLD)); + } + assert_eq!(tc.title, title); + if tc.is_execute_tool() { + assert_eq!(tc.terminal_command.as_deref(), Some(title.as_str())); + } + assert_eq!(tc.raw_input, Some(input)); + if tool_name == "mcp__docs__search" { + assert!( + rendered_line_texts(&rows[1..]) + .iter() + .any(|line| line.contains("Independent MCP result output")) + ); + } + } +} + +#[test] +fn tool_title_cache_reuses_markdown_through_animation_resize_and_body_updates() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use tracing_subscriber::layer::SubscriberExt; + + struct SyntaxWarnings(Arc); + impl tracing_subscriber::Layer for SyntaxWarnings { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + if event.metadata().target() == "tui_markdown::renderer::code" { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + } + + let warnings = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(SyntaxWarnings(Arc::clone(&warnings))); + tracing::subscriber::with_default(subscriber, || { + let mut tc = test_tool_call("title-cache", "Read", model::ToolCallStatus::InProgress); + // An indented code title exercises the actual Markdown renderer's syntax lookup. + tc.title = " cached code title\n```unrecognized\nignored tail\n```".to_owned(); + let mut first = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut first, + ); + assert_eq!(warnings.load(Ordering::Relaxed), 1); + let cached_bytes = tc.cache.cached_bytes(); + assert!(cached_bytes > 0, "title-only tools must participate in cache accounting"); + + for frame in 1..100 { + let width = [20, 60, 100][frame % 3]; + let mut rows = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + width, + crate::ui::SpinnerState::Animated(frame), + &mut rows, + ); + assert_eq!(rows.len(), 1); + assert!(spans_width(&rows[0].spans) <= usize::from(width)); + assert_eq!( + rows[0].spans[0].content, + format!(" {} ", crate::ui::SpinnerState::Animated(frame).icon()) + ); + } + assert_eq!(warnings.load(Ordering::Relaxed), 1, "unchanged titles must not be reparsed"); + + tc.content = vec![model::ToolCallContent::from("new output")]; + tc.invalidate_render_cache(); + tc.status = model::ToolCallStatus::Completed; + let mut completed = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut completed, + ); + assert_eq!(completed[0].spans[0].content, format!(" {} ", theme::ICON_COMPLETED)); + assert_eq!( + warnings.load(Ordering::Relaxed), + 1, + "body invalidation must preserve unchanged title work" + ); + + tc.title = " updated code title".to_owned(); + let mut updated = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut updated, + ); + assert_eq!(warnings.load(Ordering::Relaxed), 2); + let total_bytes = tc.cache.cached_bytes(); + assert_eq!(tc.cache.evict_cached_render(), total_bytes); + assert_eq!(tc.cache.cached_bytes(), 0); + let mut restored = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut restored, + ); + assert_eq!(restored, updated); + assert_eq!( + warnings.load(Ordering::Relaxed), + 3, + "evicted title content must be reconstructed once" + ); + }); +} + +#[test] +fn tool_title_cache_tracks_literal_rendering_and_display_context() { + let mut tc = test_tool_call("context-title", "Read", model::ToolCallStatus::InProgress); + tc.title = "**Visible title**".to_owned(); + for (tool_name, context, expected) in [ + ("Read", ToolCallRenderContext::default(), "Visible title"), + ("Bash", ToolCallRenderContext::default(), "**Visible title**"), + ("Read", ToolCallRenderContext::default(), "Visible title"), + ("Write", ToolCallRenderContext { current_mode_id: Some("plan") }, "Create Plan"), + ("Write", ToolCallRenderContext::default(), "Visible title"), + ] { + tc.sdk_tool_name = tool_name.to_owned(); + let mut rows = Vec::new(); + render_tool_call_cached( + &mut tc, + context, + 100, + crate::ui::SpinnerState::Animated(0), + &mut rows, + ); + assert_eq!(rows[0].spans.last().unwrap().content.as_ref(), expected); + } + tc.title = "**Updated title**".to_owned(); + let mut rows = Vec::new(); + render_tool_call_cached( + &mut tc, + ToolCallRenderContext::default(), + 100, + crate::ui::SpinnerState::Animated(0), + &mut rows, + ); + assert_eq!(rows[0].spans.last().unwrap().content.as_ref(), "Updated title"); +} + #[test] fn render_tool_call_title_shows_backgrounded_badge() { let mut tc = test_tool_call("tc-bg", "Agent", model::ToolCallStatus::InProgress); From f0697fe43be363774a50d667260b23b121d254af Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 17:10:15 +0200 Subject: [PATCH 6/8] perf(tui): avoid transcript replay on shutdown - redraw the shutdown composer without resetting committed history - finish pending replay and resize recovery before restoring the terminal - return reading mode to live output and preserve force exit - tolerate late interaction responses in the scripted bridge fixture - add functional terminal regressions for shutdown and recovery --- docs/src/architecture.md | 2 + src/app/events/mod.rs | 18 +- src/app/mod.rs | 64 ++++++- src/app/terminal_runtime/chat_session.rs | 6 +- tests/fixtures/fake-bridge.js | 15 +- tests/terminal_resize.rs | 204 +++++++++++++++++++++++ 6 files changed, 291 insertions(+), 18 deletions(-) diff --git a/docs/src/architecture.md b/docs/src/architecture.md index 80cda0b4..58a562b8 100644 --- a/docs/src/architecture.md +++ b/docs/src/architecture.md @@ -89,6 +89,8 @@ Presentation preferences are projections of the acknowledged config snapshot; Ru Chat reading owns a stable transcript-segment anchor and a rendered-text position; the saved Auto-scroll setting controls only the following policy. Reading suspends scrollback insertion, survives reflow of retained content, and returns to ordinary incremental history commits through an explicit keymap action. +Shutdown redraws the live viewport without resetting committed history. Reading returns to live output so its viewport copy does not remain beside native scrollback. The terminal owner finishes pending resize recovery and transcript replay before restoration; an explicit force interrupt can stop that recovery. + `/copy` derives its material from the canonical response text and the text-joining rule shared with rendering, then writes through the clipboard boundary shared with MCP authorization. Its picker owns only the current selection and retry state. ### Turn activity and thinking diff --git a/src/app/events/mod.rs b/src/app/events/mod.rs index 192f6e7c..96a0a1f6 100644 --- a/src/app/events/mod.rs +++ b/src/app/events/mod.rs @@ -83,12 +83,18 @@ impl TerminalEventOutcome { pub fn handle_terminal_event(app: &mut App, event: Event) -> TerminalEventOutcome { if app.shutdown_requested() { - if let Event::Key(key) = event - && should_dispatch_key_event(key) - && super::keys::is_ctrl_char_shortcut(key, 'c') - { - app.force_shutdown(); - return TerminalEventOutcome::handled(true); + match event { + Event::Key(key) + if should_dispatch_key_event(key) + && super::keys::is_ctrl_char_shortcut(key, 'c') => + { + app.force_shutdown(); + return TerminalEventOutcome::handled(true); + } + Event::Resize(width, height) => { + return TerminalEventOutcome::handled(handle_resize(app, width, height)); + } + _ => {} } return TerminalEventOutcome::ignored(); } diff --git a/src/app/mod.rs b/src/app/mod.rs index 9175e7ee..12d9e612 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -307,20 +307,18 @@ async fn run_tui_loop( if !is_animating && app.surface_dirty.active_surface_needs_draw(app.terminal_lifecycle) { tab_title::update_tab_title(app); } - let (width, height) = - crossterm::terminal::size().context("failed to read terminal size before draw")?; - events::reconcile_terminal_size(app, width, height); - terminal_runtime.apply_surface_rebuilds(app)?; - if app.surface_dirty.active_surface_needs_draw(app.terminal_lifecycle) { - terminal_runtime.draw_active_surface(app)?; - } + draw_pending_surface(app, terminal_runtime)?; if app.shutdown_requested() { + finish_shutdown_render(app, terminal_runtime, &mut events).await?; if let Some(events) = events.as_mut() { shutdown_connection_with_interrupts(app, events).await; } else { connect::shutdown_connection(app).await; } + // Cleanup still accepts terminal resizes. Reconcile any final size + // change and finish its recovery before restoring terminal modes. + finish_shutdown_render(app, terminal_runtime, &mut events).await?; break; } } @@ -328,6 +326,56 @@ async fn run_tui_loop( Ok(()) } +fn draw_pending_surface( + app: &mut App, + terminal_runtime: &mut terminal_runtime::TerminalRuntime, +) -> anyhow::Result<()> { + let (width, height) = + crossterm::terminal::size().context("failed to read terminal size before draw")?; + events::reconcile_terminal_size(app, width, height); + terminal_runtime.apply_surface_rebuilds(app)?; + if app.surface_dirty.active_surface_needs_draw(app.terminal_lifecycle) { + terminal_runtime.draw_active_surface(app)?; + } + Ok(()) +} + +async fn finish_shutdown_render( + app: &mut App, + terminal_runtime: &mut terminal_runtime::TerminalRuntime, + events: &mut Option, +) -> anyhow::Result<()> { + let mut interval = event_loop_interval(); + let mut os_shutdown = Box::pin(wait_for_shutdown_signal().fuse()); + // The terminal owner requests another draw when replay is incomplete or a + // resize interrupts a transaction. Keep its progress instead of resetting it. + while !app.shutdown_forced() { + draw_pending_surface(app, terminal_runtime)?; + if !app.surface_dirty.active_surface_needs_draw(app.terminal_lifecycle) { + break; + } + tokio::select! { + Some(Ok(event)) = next_terminal_event(events) => { + let _ = events::handle_terminal_event(app, event); + } + signal = &mut os_shutdown => { + match signal { + Ok(()) => app.force_shutdown(), + Err(err) => tracing::warn!( + target: crate::logging::targets::APP_LIFECYCLE, + event_name = "shutdown_force_listener_failed", + message = "OS shutdown listener failed during final rendering", + outcome = "degraded", + error_message = %err, + ), + } + } + _ = interval.tick() => {} + } + } + Ok(()) +} + fn handle_runtime_command( app: &mut App, terminal_runtime: &mut terminal_runtime::TerminalRuntime, @@ -439,7 +487,7 @@ fn prepare_app_shutdown(app: &mut App) { app.mention = None; app.slash.clear(); app.subagent = None; - app.request_chat_visible_rebuild(); + app.request_chat_repaint(); // Dismiss all pending inline permissions (reject via last option) for tool_id in std::mem::take(&mut app.pending_interaction_ids) { diff --git a/src/app/terminal_runtime/chat_session.rs b/src/app/terminal_runtime/chat_session.rs index 8722dab2..bf647fa2 100644 --- a/src/app/terminal_runtime/chat_session.rs +++ b/src/app/terminal_runtime/chat_session.rs @@ -193,7 +193,11 @@ impl ChatTerminalSession { let width = screen_size.0.max(1); let terminal_height = screen_size.1.max(1); - if !app.config.auto_scroll_effective() { + if app.shutdown_requested() { + // Reading displays a copy of committed history in the live viewport. + // Remove that copy before leaving the transcript to the terminal. + app.chat_render.viewport.resume(); + } else if !app.config.auto_scroll_effective() { app.chat_render.viewport.pause(); } let base_excluded_ids = self.base_history_excluded_ids(); diff --git a/tests/fixtures/fake-bridge.js b/tests/fixtures/fake-bridge.js index 8b587b7d..5a0de2e8 100644 --- a/tests/fixtures/fake-bridge.js +++ b/tests/fixtures/fake-bridge.js @@ -225,7 +225,7 @@ function streamReply(messageUuid) { alert({ ...proactive, tool_use_id: `upstream-${replyNumber}`, local_sent: true }); send({ event: 'session_update', session_id: SESSION, update: { type: 'notification_update', notification: { ...proactive, tool_use_id: `replay-${replyNumber}` }, replay: true } }); } - if (SCENARIO === 'resize-replay') { + if (SCENARIO.startsWith('resize-replay')) { const text = Array.from({ length: LINES }, (_, index) => `${index + 1}. streamed line ${index + 1}\n`).join(''); send({ event: 'session_update', session_id: SESSION, update: { type: 'agent_message_chunk', content: { type: 'text', text }, source_message_uuid: null } }); send({ event: 'turn_complete', session_id: SESSION, terminal_reason: 'completed', queued_turn_count: 0 }); @@ -421,7 +421,9 @@ readline record({ type: 'barrier', name: 'background-permission-answered' }); break; } - if (!active || message.tool_call_id !== 'fixture-tool') throw new Error('Unexpected interaction response'); + if (message.tool_call_id !== 'fixture-tool') throw new Error('Unexpected interaction response'); + // Like the real bridge, tolerate a resolver disappearing on cancellation. + if (!active) break; send({ event: 'session_update', session_id: SESSION, update: { type: 'tool_call_update', tool_call_update: { tool_call_id: 'fixture-tool', source_message_uuid: null, @@ -431,7 +433,14 @@ readline finish({ event: 'turn_complete', session_id: SESSION }); break; case 'shutdown': - process.exit(0); + if (SCENARIO === 'resize-replay-shutdown-held') { + record({ type: 'barrier', name: 'shutdown-held' }); + setInterval(() => { + if (fs.existsSync(RELEASE_FILE) && fs.readFileSync(RELEASE_FILE, 'utf8') === 'close') process.exit(0); + }, INTERVAL_MS); + } else { + process.exit(0); + } } }) .on('close', () => process.exit(0)); diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index d586d53a..5ad27465 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -600,6 +600,10 @@ impl TerminalTest { fn shutdown(&mut self) { self.send(b"\x11"); // Normal Ctrl+Q shutdown, not a forced kill. + self.wait_shutdown(); + } + + fn wait_shutdown(&mut self) { let status = self.child.wait_for_exit(Duration::from_secs(15)); assert!( status.as_ref().is_some_and(ExitStatus::success), @@ -614,6 +618,206 @@ impl TerminalTest { let text = String::from_utf8_lossy(&output.raw); assert!(!text.contains(OWNED_REGION_ERROR), "{}", tail(&text, 2000)); } + + fn wait_shutdown_screen(&self) { + // ConPTY consumes focus-reporting and line-wrap mode changes. The + // shutdown composer hides its cursor; terminal restoration shows it. + let started = Instant::now(); + loop { + let screen = self.output.lock().expect("output lock").parser.screen().clone(); + if screen.contents().contains("Shutting down") && !screen.hide_cursor() { + return; + } + assert!( + started.elapsed() < WAIT_LIMIT, + "final shutdown screen: {}", + self.diagnostics() + ); + std::thread::sleep(Duration::from_millis(20)); + } + } +} + +#[test] +fn shutdown_preserves_completed_history_without_reinserting_it() { + for mode in ["live", "reading", "fullscreen"] { + let mut test = TerminalTest::start("resize-replay", 240); + test.submit("SHUTDOWN_HISTORY", "SHUTDOWN_HISTORY"); + test.wait_turn_finished("streamed line 240"); + test.wait_completed_reply_in_scrollback(240); + if mode == "reading" { + test.send(b"\x1b[5~"); + test.wait_screen("Reading output"); + } else if mode == "fullscreen" { + test.submit("/config", "/config"); + test.wait_settings_ready("Description:"); + assert!(test.output.lock().expect("output lock").parser.screen().alternate_screen()); + } + let offset = + std::fs::read(test.temp.path().join("runtime.log")).expect("runtime log").len(); + test.shutdown(); + test.wait_shutdown_screen(); + let rows = test.transcript_rows(); + for line in 1..=240 { + let suffix = format!("streamed line {line}"); + assert_eq!( + rows.iter().filter(|row| row.trim_end().ends_with(&suffix)).count(), + 1, + "{mode}: completed row {line} must survive exactly once" + ); + } + let records = test.runtime_records_since(offset); + assert!( + !records + .iter() + .any(|record| record["event_name"] == "inline_chat_scrollback_insert_applied"), + "{mode}: closing must preserve committed history without writing it again" + ); + let screen = test.output.lock().expect("output lock").parser.screen().clone(); + assert!(!screen.alternate_screen(), "shutdown must return to the main screen"); + assert!(!screen.hide_cursor(), "shutdown must restore the cursor"); + assert!(screen.contents().contains("Shutting down")); + } +} + +#[test] +fn shutdown_finishes_pending_replay_and_reconciles_a_further_resize() { + let mut test = TerminalTest::start("resize-replay-shutdown-held", 2_000); + test.submit("SHUTDOWN_REPLAY", "SHUTDOWN_REPLAY"); + test.wait_turn_finished("streamed line 2000"); + test.wait_completed_reply_in_scrollback(2_000); + let offset = std::fs::read(test.temp.path().join("runtime.log")).expect("runtime log").len(); + test.resize(12, 64); + test.wait_until("incomplete transcript recovery", |test| { + test.runtime_records_since(offset).iter().any(|record| { + record["event_name"] == "inline_chat_terminal_draw_transaction" + && record["replay_incomplete"] == true + }) + }); + test.send(b"\x11"); + test.wait_until("shutdown while recovery remains pending", |test| { + test.runtime_records_since(offset).iter().any(|record| { + record["event_name"] == "inline_chat_draw_summary" + && record["composer_preview"] + .as_str() + .is_some_and(|text| text.contains("Shutting down")) + }) + }); + test.resize(18, 72); + test.wait_journal("shutdown-held"); + // A second resize happens after the first rendering drain, while the bridge + // is still cleaning up. It must finish replay before terminal restoration. + test.resize(25, 81); + test.signal("close"); + test.wait_shutdown(); + test.wait_shutdown_screen(); + let records = test.runtime_records_since(offset); + assert!( + records.iter().any(|record| { + record["event_name"] == "inline_chat_viewport_draw" + && record["terminal_height"] == 18 + && record["terminal_width"] == 72 + }), + "shutdown must reconcile the terminal dimensions during recovery" + ); + assert!( + records.iter().any(|record| { + record["event_name"] == "inline_chat_viewport_draw" + && record["terminal_height"] == 25 + && record["terminal_width"] == 81 + }), + "shutdown must recover a resize during bridge cleanup" + ); + assert!( + records.iter().any(|record| { + record["event_name"] == "inline_chat_terminal_draw_transaction" + && record["replay_complete"] == true + }), + "shutdown must finish the pending transcript replay" + ); + let rows = test.transcript_rows(); + for line in 1..=2_000 { + let suffix = format!("streamed line {line}"); + assert_eq!( + rows.iter().filter(|row| row.trim_end().ends_with(&suffix)).count(), + 1, + "recovered row {line} must survive exactly once" + ); + } +} + +#[test] +fn shutdown_keeps_received_output_and_dismisses_pending_interactions() { + for scenario in ["hold-success", "permission", "question"] { + let mut test = TerminalTest::start(scenario, 8); + test.submit("SHUTDOWN_ACTIVE", "SHUTDOWN_ACTIVE"); + let marker = match scenario { + "permission" => "Allow once", + "question" => "Choose fixture destination", + _ => "streamed line 8", + }; + test.wait_screen(marker); + if scenario == "hold-success" { + test.wait_journal("reply-held"); + } + test.shutdown(); + test.wait_shutdown_screen(); + let screen = test.screen(); + assert!(screen.contains("Shutting down")); + assert!(!screen.contains("Allow once") && !screen.contains("Choose fixture destination")); + assert_eq!(test.commands("cancel_turn").len(), 1); + if scenario == "hold-success" { + let rows = test.transcript_rows(); + for line in 1..=8 { + let suffix = format!("streamed line {line}"); + assert_eq!(rows.iter().filter(|row| row.trim_end().ends_with(&suffix)).count(), 1); + } + } else { + let command = + if scenario == "permission" { "permission_response" } else { "question_response" }; + let responses = test.commands(command); + assert_eq!(responses.len(), 1); + assert_eq!(responses[0]["tool_call_id"], "fixture-tool"); + assert_eq!(responses[0]["session_id"], "fake-session"); + let outcome = if scenario == "permission" { + serde_json::json!({"outcome": "selected", "option_id": "deny-once"}) + } else { + serde_json::json!({"outcome": "cancelled"}) + }; + assert_eq!(responses[0]["outcome"], outcome); + } + } +} + +#[test] +fn shutdown_force_interrupt_can_stop_pending_transcript_recovery() { + let mut test = TerminalTest::start("resize-replay", 2_000); + test.submit("FORCE_SHUTDOWN_REPLAY", "FORCE_SHUTDOWN_REPLAY"); + test.wait_turn_finished("streamed line 2000"); + test.wait_completed_reply_in_scrollback(2_000); + let offset = std::fs::read(test.temp.path().join("runtime.log")).expect("runtime log").len(); + test.resize(12, 64); + test.wait_until("incomplete transcript recovery", |test| { + test.runtime_records_since(offset).iter().any(|record| { + record["event_name"] == "inline_chat_terminal_draw_transaction" + && record["replay_incomplete"] == true + }) + }); + test.send(b"\x11\x03"); // Request shutdown, then explicitly force it. + let status = test.child.wait_for_exit(Duration::from_secs(15)); + assert!(status.as_ref().is_some_and(ExitStatus::success), "force exit: {}", test.diagnostics()); + test.wait_shutdown_screen(); + assert!( + !test.runtime_records_since(offset).iter().any(|record| { + record["event_name"] == "inline_chat_terminal_draw_transaction" + && record["replay_complete"] == true + }), + "force exit must not wait for the remaining transcript replay" + ); + assert!( + test.missing_reply_row(2_000).is_some(), + "the fixture must still have pending recovery" + ); } #[test] From 07c19b691fca98d3fe5610d68b07bbbdaa680c84 Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 17:27:56 +0200 Subject: [PATCH 7/8] fix(tests): remove duplicate terminal settings helpers - remove the duplicate settings refresh and save wait methods - retain the existing helpers and all functional test callers --- tests/terminal_resize.rs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/tests/terminal_resize.rs b/tests/terminal_resize.rs index 9a799daa..5ad27465 100644 --- a/tests/terminal_resize.rs +++ b/tests/terminal_resize.rs @@ -495,23 +495,6 @@ impl TerminalTest { }); } - /// Opening settings starts a refresh, and settings ignore edits until it completes. - fn wait_settings_ready(&mut self, needle: &str) { - self.wait_until(&format!("refreshed settings showing {needle}"), |test| { - let screen = test.screen(); - screen.contains(needle) && !screen.contains("Refreshing settings") - }); - } - - /// An editor ignores keys until its save is acknowledged, which closes it - /// and takes its `draft` off the screen. - fn wait_settings_saved(&mut self, saved: &str, draft: &str) { - self.wait_until(&format!("saved settings showing {saved}"), |test| { - let screen = test.screen(); - screen.contains(saved) && !screen.contains(draft) - }); - } - fn wait_setting(&mut self, file: &str, pointer: &str, expected: Option<&Value>) { self.wait_until(&format!("persisted {pointer}"), |test| { std::fs::read(test.temp.path().join(file)) From 3857f5f2096f9589e0af7f114a1c2d08f22c9e3f Mon Sep 17 00:00:00 2001 From: Simon Peter Rothgang Date: Tue, 6 Oct 2026 17:36:29 +0200 Subject: [PATCH 8/8] fix(agent-sdk): avoid backtracking in resumed task notifications - extract notification wrappers and fields with literal tag searches - preserve task outcomes and tool linkage while skipping incomplete required tags - add resume regressions for valid, malformed and large unclosed notifications --- agent-sdk/src/bridge/history.test.ts | 85 ++++++++++++++++++++++++++++ agent-sdk/src/bridge/history.ts | 14 ++++- 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/agent-sdk/src/bridge/history.test.ts b/agent-sdk/src/bridge/history.test.ts index 78283d8d..10a50d0c 100644 --- a/agent-sdk/src/bridge/history.test.ts +++ b/agent-sdk/src/bridge/history.test.ts @@ -4,6 +4,7 @@ import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSy import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import test from "node:test"; +import type { SessionUpdate } from "../types.js"; import { mapSessionMessagesToUpdates } from "./history.js"; test("resume display restores compaction segments once and preserves SDK branch filtering", () => { @@ -100,6 +101,90 @@ test("resume display excludes internal task notifications while preserving user assert.deepEqual(messages, before); }); +test("resume notifications preserve terminal outcomes, linked tools and optional fields", () => { + const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; + for (const [status, expectedStatus] of [["completed", "completed"], ["failed", "failed"], ["stopped", "killed"]] as const) { + const outputFile = "C:\\Temp\\claude\\tasks\\monitor.output"; + const summary = "Background command finished\nSecond line with literal XML"; + const notification = `\n task-${status} \n monitor \n ${outputFile} \n ${status} \n ${summary} \nExtra SDK field\n`; + for (const content of [notification, [{ type: "text", text: notification.slice(0, notification.indexOf("")) }, { type: "text", text: notification.slice(notification.indexOf("")) }]]) { + const updates = mapSessionMessagesToUpdates([ + { ...base, type: "assistant", uuid: "launch", message: { role: "assistant", content: [{ type: "tool_use", id: "monitor", name: "Monitor", input: { command: "check" } }] } }, + { ...base, type: "user", uuid: "result", ...{ origin: { kind: "task-notification", producer: "session-task" } }, message: { role: "user", content } }, + ]); + const results = updates.filter(update => update.type === "tool_call_update"); + assert.equal(results.length, 1); + assert.equal(results[0].tool_call_update.tool_call_id, "monitor"); + assert.equal(results[0].tool_call_update.fields.status, expectedStatus); + assert.equal(results[0].tool_call_update.fields.raw_output, summary); + assert.equal(results[0].tool_call_update.fields.task_metadata?.output_file, outputFile); + assert.equal(results[0].tool_call_update.fields.task_metadata?.terminal_status, status); + const tasks = updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks); + assert.deepEqual(tasks.map(task => [task.task_id, task.status, task.source_tool_call_id, task.description, task.metadata]), [[`task-${status}`, "completed", "monitor", summary, { output_file: outputFile, summary, terminal_status: status }]]); + assert.ok(!updates.some(update => update.type === "user_message_chunk")); + } + } + const minimal = mapSessionMessagesToUpdates([ + { ...base, type: "user", uuid: "minimal", ...{ origin: { kind: "task-notification" } }, message: { role: "user", content: "minimalcompleted" } }, + ]); + assert.deepEqual(minimal.filter(update => update.type === "task_state_update").flatMap(update => update.tasks).map(task => [task.task_id, task.status, task.source_tool_call_id, task.description]), [["minimal", "completed", undefined, undefined]]); +}); + +test("resume skips malformed internal notifications and restores later conversation and task outcomes", () => { + const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; + const malformed = [ + "badcompleted", + "badcompleted", + "", + " completed", + "badcompleted", + "bad", + "badrunning", + "badcompleted", + "badcompleted", + ]; + const updates = mapSessionMessagesToUpdates([ + ...malformed.map((content, index) => ({ ...base, type: "user" as const, uuid: `broken-${index}`, origin: { kind: "task-notification" }, message: { role: "user", content } })), + { ...base, type: "user", uuid: "after", message: { role: "user", content: "Continue after the damaged record" } }, + { ...base, type: "assistant", uuid: "reply", message: { role: "assistant", content: "Conversation restored" } }, + { ...base, type: "user", uuid: "valid", ...{ origin: { kind: "task-notification" } }, message: { role: "user", content: "validcompleted" } }, + ]); + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk" || update.type === "agent_message_chunk").map(update => [update.source_message_uuid, update.content]), [ + ["after", { type: "text", text: "Continue after the damaged record" }], + ["reply", { type: "text", text: "Conversation restored" }], + ]); + assert.deepEqual(updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks).map(task => [task.task_id, task.status]), [["valid", "completed"]]); +}); + +test("resume handles many unclosed notification and field tags without blocking the remaining transcript", () => { + // A process timeout bounds the regression even if synchronous parsing stalls. + const child = spawnSync(process.execPath, ["--input-type=module", "-"], { + input: ` + import { mapSessionMessagesToUpdates } from ${JSON.stringify(new URL("./history.js", import.meta.url).href)}; + const broken = [ + "a".repeat(200_000), + "" + "a".repeat(200_000) + "completed", + "bad" + "a".repeat(200_000) + "", + "validcompleted" + "abc".repeat(200_000) + "", + ]; + const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; + const updates = mapSessionMessagesToUpdates([ + ...broken.map((content, index) => ({ ...base, type: "user", uuid: "broken-" + index, origin: { kind: "task-notification" }, message: { role: "user", content } })), + { ...base, type: "user", uuid: "after", message: { role: "user", content: "Still restored" } }, + ]); + process.stdout.write(JSON.stringify(updates)); + `, + encoding: "utf8", + timeout: 10_000, + windowsHide: true, + }); + assert.equal(child.error, undefined, String(child.error)); + assert.equal(child.status, 0, child.stderr); + const updates = JSON.parse(child.stdout) as SessionUpdate[]; + assert.deepEqual(updates.filter(update => update.type === "user_message_chunk").map(update => [update.source_message_uuid, update.content]), [["after", { type: "text", text: "Still restored" }]]); + assert.deepEqual(updates.filter(update => update.type === "task_state_update").flatMap(update => update.tasks).map(task => [task.task_id, task.status, task.source_tool_call_id, task.description]), [["valid", "completed", undefined, undefined]]); +}); + test("resume display maps plain string messages and hides internal compact summaries", () => { const base = { session_id: "fixture", parent_tool_use_id: null, parent_agent_id: null }; const updates = mapSessionMessagesToUpdates([ diff --git a/agent-sdk/src/bridge/history.ts b/agent-sdk/src/bridge/history.ts index c7648342..c7056671 100644 --- a/agent-sdk/src/bridge/history.ts +++ b/agent-sdk/src/bridge/history.ts @@ -434,13 +434,23 @@ export function restoreResumedToolState(session: SessionState, updates: SessionU } } +// Literal searches avoid retrying overlapping suffixes when a closing tag is missing. +function notificationTagContent(text: string, name: string): string | undefined { + const opening = `<${name}>`; + const start = text.indexOf(opening); + if (start === -1) return undefined; + const bodyStart = start + opening.length; + const end = text.indexOf(``, bodyStart); + return end === -1 ? undefined : text.slice(bodyStart, end); +} + function resumedTaskNotification(raw: unknown): Record | undefined { for (const message of messageCandidates(raw)) { const content = typeof message.content === "string" ? message.content : Array.isArray(message.content) ? message.content.map(block => asRecordOrNull(block)?.text ?? "").join("\n") : ""; - const body = content.match(/([\s\S]*?)<\/task-notification>/)?.[1]; + const body = notificationTagContent(content, "task-notification"); if (!body) continue; - const field = (name: string) => body.match(new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`))?.[1]?.trim(); + const field = (name: string) => notificationTagContent(body, name)?.trim(); const taskId = field("task-id"); const status = field("status"); if (taskId && (status === "completed" || status === "failed" || status === "stopped")) {