From 25ab0746528a16a0cd0890fe62bc48dfd8851335 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 14:07:35 -0700 Subject: [PATCH 01/12] acpmux: test that an adopted chat replays its tool calls (failing) A reopened Codex turn that ran commands and edited two files replays only its prompt and final answer (hq#2569 item 3). Boundary tests: a Codex rollout fixture and a Claude Code transcript through adoption, and a pane browser test over the daemon's own replayed records (generated from the Rust test; this commit's copy is the base's output, without tool calls). Co-Authored-By: Claude Opus 5.5 --- .../fixtures/codex-rollout-tool-calls.jsonl | 14 + .../crates/acpmux/tests/hub_integration.rs | 6 +- .../acpmux/tests/hub_integration/adopt.rs | 248 ++++++++++++++++++ .../tests/hub_integration/fork_through.rs | 2 +- .../agent-pane-adopted-tool-calls.test.ts | 147 +++++++++++ .../fixtures/adopted-codex-turn.events.json | 71 +++++ 6 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 cmux-tui/crates/acpmux/tests/fixtures/codex-rollout-tool-calls.jsonl create mode 100644 webviews/test/agent-pane-adopted-tool-calls.test.ts create mode 100644 webviews/test/fixtures/adopted-codex-turn.events.json diff --git a/cmux-tui/crates/acpmux/tests/fixtures/codex-rollout-tool-calls.jsonl b/cmux-tui/crates/acpmux/tests/fixtures/codex-rollout-tool-calls.jsonl new file mode 100644 index 000000000000..ffacc614bae1 --- /dev/null +++ b/cmux-tui/crates/acpmux/tests/fixtures/codex-rollout-tool-calls.jsonl @@ -0,0 +1,14 @@ +{"timestamp": "2026-10-02T08:59:59.900Z", "type": "session_meta", "payload": {"id": "{ID}", "cwd": "{CWD}", "originator": "Codex Desktop", "cli_version": "0.163.0"}} +{"timestamp": "2026-10-02T08:59:59.950Z", "type": "event_msg", "payload": {"type": "task_started", "turn_id": "turn-1"}} +{"timestamp": "2026-10-02T08:59:59.980Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "# AGENTS.md instructions for {CWD}\n\nKeep the changelog current."}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["agents_md.instructions"]}}} +{"timestamp": "2026-10-02T09:00:00.000Z", "type": "response_item", "payload": {"type": "message", "role": "user", "content": [{"type": "input_text", "text": "Bump the version to 1.3.0 and note it in the changelog"}], "internal_chat_message_metadata_passthrough": {"content_item_kinds": ["user.text"]}}} +{"timestamp": "2026-10-02T09:00:00.010Z", "type": "event_msg", "payload": {"type": "item_completed", "thread_id": "{ID}", "turn_id": "turn-1", "item": {"type": "UserMessage", "id": "u-1", "content": [{"type": "text", "text": "Bump the version to 1.3.0 and note it in the changelog"}]}}} +{"timestamp": "2026-10-02T09:00:04.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "phase": "commentary", "content": [{"type": "output_text", "text": "I'll check the current version and changelog first."}]}} +{"timestamp": "2026-10-02T09:00:05.000Z", "type": "response_item", "payload": {"type": "custom_tool_call", "status": "completed", "call_id": "call-1", "name": "exec", "input": "const r = await tools.exec_command({cmd: \"git status --short\"}); text(r.output);"}} +{"timestamp": "2026-10-02T09:00:06.000Z", "type": "event_msg", "payload": {"type": "item_completed", "thread_id": "{ID}", "turn_id": "turn-1", "item": {"type": "CommandExecution", "id": "exec-1", "command": ["/bin/zsh", "-lc", "git status --short"], "cwd": "file://{CWD}", "parsed_cmd": [{"type": "unknown", "cmd": "git status --short"}], "source": "unified_exec_startup", "status": "completed", "aggregated_output": "", "exit_code": 0, "duration": {"secs": 0, "nanos": 210000000}}}} +{"timestamp": "2026-10-02T09:00:06.100Z", "type": "response_item", "payload": {"type": "custom_tool_call_output", "call_id": "call-1", "output": [{"type": "input_text", "text": "Script completed\n"}]}} +{"timestamp": "2026-10-02T09:00:09.000Z", "type": "event_msg", "payload": {"type": "item_completed", "thread_id": "{ID}", "turn_id": "turn-1", "item": {"type": "CommandExecution", "id": "exec-2", "command": ["/bin/zsh", "-lc", "grep -n '^version' Cargo.toml"], "cwd": "file://{CWD}", "parsed_cmd": [{"type": "unknown", "cmd": "grep -n '^version' Cargo.toml"}], "source": "unified_exec_startup", "status": "completed", "aggregated_output": "3:version = \"1.2.0\"\n", "exit_code": 0, "duration": {"secs": 0, "nanos": 50000000}}}} +{"timestamp": "2026-10-02T09:00:12.000Z", "type": "event_msg", "payload": {"type": "item_completed", "thread_id": "{ID}", "turn_id": "turn-1", "item": {"type": "CommandExecution", "id": "exec-3", "command": ["/bin/zsh", "-lc", "sed -n 1,8p CHANGELOG.md"], "cwd": "file://{CWD}", "parsed_cmd": [{"type": "unknown", "cmd": "sed -n 1,8p CHANGELOG.md"}], "source": "unified_exec_startup", "status": "completed", "aggregated_output": "# Changelog\n\n## 1.2.0\n- Faster startup\n", "exit_code": 0, "duration": {"secs": 0, "nanos": 40000000}}}} +{"timestamp": "2026-10-02T09:01:10.000Z", "type": "event_msg", "payload": {"type": "item_completed", "thread_id": "{ID}", "turn_id": "turn-1", "item": {"type": "FileChange", "id": "exec-4", "status": "completed", "changes": {"{CWD}/Cargo.toml": {"type": "update", "unified_diff": "@@ -1,4 +1,4 @@\n [package]\n name = \"demo\"\n-version = \"1.2.0\"\n+version = \"1.3.0\"\n edition = \"2021\"\n", "move_path": null}, "{CWD}/CHANGELOG.md": {"type": "update", "unified_diff": "@@ -1,3 +1,6 @@\n # Changelog\n \n+## 1.3.0\n+- Bump the version to 1.3.0\n+\n ## 1.2.0\n", "move_path": null}}, "stdout": "Success. Updated the following files:\nM {CWD}/Cargo.toml\nM {CWD}/CHANGELOG.md\n", "stderr": ""}}} +{"timestamp": "2026-10-02T09:01:32.000Z", "type": "response_item", "payload": {"type": "message", "role": "assistant", "phase": "final_answer", "content": [{"type": "output_text", "text": "Bumped demo to 1.3.0 and added a 1.3.0 entry to CHANGELOG.md."}]}} +{"timestamp": "2026-10-02T09:01:32.100Z", "type": "event_msg", "payload": {"type": "task_complete", "turn_id": "turn-1", "last_agent_message": "Bumped demo to 1.3.0 and added a 1.3.0 entry to CHANGELOG.md."}} diff --git a/cmux-tui/crates/acpmux/tests/hub_integration.rs b/cmux-tui/crates/acpmux/tests/hub_integration.rs index 2a74e92afb05..e7d5a9cc06b7 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration.rs @@ -2,8 +2,10 @@ #[path = "support/daemon_env.rs"] mod daemon_env; -// This process runs stand-in agents (the fake agents under tests/). -const HERMETIC: daemon_env::Process = daemon_env::Process::stand_in(); +// This process runs stand-in agents (the fake agents under tests/). `ACPMUX_UPDATE_FIXTURES=1` +// rewrites the pane fixtures the tests check (`adopt.rs`). +const HERMETIC: daemon_env::Process = + daemon_env::Process::stand_in().keep(&["ACPMUX_UPDATE_FIXTURES"]); use acpmux::config::{Config, HarnessProfile, PermissionPolicy, StoreMode}; use acpmux::hub::Hub; diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs b/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs index 7fd53c1d9caf..2239d2fa60a9 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs @@ -303,3 +303,251 @@ async fn adopt_forks_a_live_claude_chat_when_asked() { let _ = harness.wait(); let _ = std::fs::remove_dir_all(root); } + +/// The replayed history of an adopted chat: its records from the first `turn_started` on. +fn replayed(hub: &Hub, id: &str) -> Vec { + let events = hub.events(id, 0, 10_000).unwrap(); + let start = events.iter().position(|e| e.kind == "turn_started").expect("a replayed turn"); + events[start..].to_vec() +} + +/// The session update of an `in` record. +fn update_of(e: &acpmux::store::EventRecord) -> &Value { + e.msg.pointer("/params/update").unwrap_or(&Value::Null) +} + +/// An adopted Codex chat shows each turn's tool calls, not only its final message +/// (hq#2569 item 3): a reopened turn that ran three commands and edited two files replays +/// them as `tool_call` records, each settled by a `tool_call_update` with its output, before +/// the answer, so the pane's "Worked for" opens to "Ran 3 commands" and "Edited 2 files". +/// The edit carries each file's before and after text and +/- counts (the edited-files card). +/// +/// The replayed records are also the pane's fixture +/// (`webviews/test/fixtures/adopted-codex-turn.events.json`, read by +/// `agent-pane-adopted-tool-calls.test.ts`): `ACPMUX_UPDATE_FIXTURES=1` rewrites it. +#[tokio::test] +async fn adopt_replays_a_codex_turns_commands_and_file_edits() { + let (hub, mut c, root, project, chat) = adopt_setup().await; + let rollout = root.join(format!("codex/sessions/2026/10/02/rollout-2026-10-02T09-00-00-{chat}.jsonl")); + let fixture = include_str!("../fixtures/codex-rollout-tool-calls.jsonl") + .replace("{ID}", &chat) + .replace("{CWD}", &project.to_string_lossy()); + std::fs::write(&rollout, fixture).unwrap(); + backdate(&rollout); + + let s = c.request(method::SESSION_NEW, adopt("fakecodex", &chat)).await.unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + let events = replayed(&hub, &id); + write_pane_fixture(&events, &chat, &root); + let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect(); + assert_eq!( + kinds, + [ + "turn_started", + "user_message", + "tool_call", + "tool_call_update", + "tool_call", + "tool_call_update", + "tool_call", + "tool_call_update", + "tool_call", + "tool_call_update", + "agent_message_chunk", + "turn_end", + "turn_result" + ], + "{events:#?}" + ); + assert_eq!(events[1].msg["text"], "Bump the version to 1.3.0 and note it in the changelog"); + let calls: Vec<&Value> = + events.iter().filter(|e| e.kind == "tool_call").map(update_of).collect(); + let commands: Vec<&str> = + calls.iter().filter_map(|u| u.pointer("/rawInput/command").and_then(Value::as_str)).collect(); + assert_eq!( + commands, + ["git status --short", "grep -n '^version' Cargo.toml", "sed -n 1,8p CHANGELOG.md"] + ); + assert!(calls[..3].iter().all(|u| u["kind"] == "execute" && u["status"] == "in_progress")); + assert_eq!(calls[0]["rawInput"]["cwd"], json!(project)); + // Each command settles with its output and exit code, at the time it ended. + let done: Vec<&acpmux::store::EventRecord> = + events.iter().filter(|e| e.kind == "tool_call_update").collect(); + let grep = update_of(done[1]); + assert_eq!(grep["toolCallId"], "exec-2"); + assert_eq!(grep["status"], "completed"); + assert_eq!(grep["rawOutput"]["exit_code"], 0); + assert_eq!(grep.pointer("/content/0/content/text"), Some(&json!("3:version = \"1.2.0\"\n"))); + let started = events.iter().find(|e| e.kind == "tool_call" && update_of(e)["toolCallId"] == "exec-2"); + assert_eq!(done[1].at - started.unwrap().at, 50, "the call ran 50ms"); + + // The edit: one call over two files (in path order: Codex records them as a map), each a + // diff with its before and after text. + let edit = calls[3]; + assert_eq!(edit["kind"], "edit"); + let cargo = project.join("Cargo.toml").to_string_lossy().into_owned(); + let changelog = project.join("CHANGELOG.md").to_string_lossy().into_owned(); + assert_eq!(edit["locations"], json!([{"path": changelog, "line": 1}, {"path": cargo, "line": 1}])); + let edit_done = update_of(done[3]); + assert_eq!(edit_done["status"], "completed"); + let diffs = edit_done["content"].as_array().unwrap(); + assert_eq!(diffs.len(), 2, "{edit_done}"); + assert_eq!(diffs[0]["type"], "diff"); + assert_eq!(diffs[0]["path"], changelog.as_str()); + assert!(diffs[0]["newText"].as_str().unwrap().contains("## 1.3.0\n- Bump the version to 1.3.0")); + assert_eq!(diffs[1]["path"], cargo.as_str()); + assert!(diffs[1]["oldText"].as_str().unwrap().contains("version = \"1.2.0\"")); + assert!(diffs[1]["newText"].as_str().unwrap().contains("version = \"1.3.0\"")); + assert_eq!( + edit["rawInput"]["changes"], + json!([ + {"path": changelog, "type": "update", "additions": 3, "deletions": 0}, + {"path": cargo, "type": "update", "additions": 1, "deletions": 1} + ]) + ); + + // The final answer only (commentary is progress narration), after the work. + let answer = &events[10]; + assert_eq!( + update_of(answer).pointer("/content/text"), + Some(&json!("Bumped demo to 1.3.0 and added a 1.3.0 entry to CHANGELOG.md.")) + ); + // Worked for 1m 32s: the prompt at 09:00:00, the answer at 09:01:32. + assert_eq!(answer.at - events[1].at, 92_000); + + let _ = std::fs::remove_dir_all(root); +} + +/// Checks (or with `ACPMUX_UPDATE_FIXTURES=1` rewrites) the pane's adopted-turn fixture: the +/// replayed records with the machine-specific folder and ids made stable, and numbered from 1 +/// (`seq`, and the `turnSeq` that names the turn's start). +fn write_pane_fixture(events: &[acpmux::store::EventRecord], chat: &str, root: &std::path::Path) { + let base = events[0].seq - 1; + let records: Vec = events + .iter() + .map(|e| { + let mut msg = e.msg.clone(); + if let Some(turn) = msg.get("turnSeq").and_then(Value::as_u64) { + msg["turnSeq"] = json!(turn - base); + } + json!({"seq": e.seq - base, "at": e.at, "dir": e.dir, "kind": e.kind, "msg": msg}) + }) + .collect(); + let text = serde_json::to_string_pretty(&records) + .unwrap() + .replace(&root.join("project").to_string_lossy().into_owned(), "/home/dev/demo") + .replace(chat, "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60") + + "\n"; + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../webviews/test/fixtures/adopted-codex-turn.events.json" + ); + if std::env::var("ACPMUX_UPDATE_FIXTURES").as_deref() == Ok("1") { + std::fs::write(path, &text).unwrap(); + } else if let Ok(stored) = std::fs::read_to_string(path) { + // A checkout without the webviews (a crate-only build) has nothing to check. + assert_eq!(stored, text, "the pane's fixture is stale: rerun with ACPMUX_UPDATE_FIXTURES=1"); + } +} + +/// An adopted Claude Code chat shows its tool calls too: a `tool_use` replays as a +/// `tool_call` and its `tool_result` settles it (an error result fails it), before the answer. +#[tokio::test] +async fn adopt_replays_a_claude_turns_tool_calls() { + let root = std::path::PathBuf::from("/tmp") + .join(format!("acpmux-adopt-claude-tools-{}", uuid::Uuid::now_v7())); + let claude = root.join("claude"); + let project = root.join("project"); + std::fs::create_dir_all(&project).unwrap(); + let chat = session_id(); + let file = project.join("src/lib.rs").to_string_lossy().into_owned(); + let line = |kind: &str, content: Value, at: &str| { + json!({"type": kind, "isSidechain": false, "cwd": project, "sessionId": chat, + "timestamp": at, "message": {"role": kind, "content": content}}) + }; + let lines = [ + line("user", json!("Rename old_helper and run the tests"), "2026-10-02T10:00:00.000Z"), + line( + "assistant", + json!([{"type": "tool_use", "id": "toolu_1", "name": "Bash", "input": {"command": "rg -n old_helper", "description": "Find uses"}}]), + "2026-10-02T10:00:03.000Z", + ), + line( + "user", + json!([{"type": "tool_result", "tool_use_id": "toolu_1", "content": "src/lib.rs:3:fn old_helper() {}"}]), + "2026-10-02T10:00:04.000Z", + ), + line( + "assistant", + json!([{"type": "tool_use", "id": "toolu_2", "name": "Edit", "input": {"file_path": file, "old_string": "fn old_helper() {}", "new_string": "fn new_helper() {}"}}]), + "2026-10-02T10:00:06.000Z", + ), + line( + "user", + json!([{"type": "tool_result", "tool_use_id": "toolu_2", "content": [{"type": "text", "text": "The file has been updated."}]}]), + "2026-10-02T10:00:07.000Z", + ), + line( + "assistant", + json!([{"type": "tool_use", "id": "toolu_3", "name": "Bash", "input": {"command": "cargo test -q"}}]), + "2026-10-02T10:00:08.000Z", + ), + line( + "user", + json!([{"type": "tool_result", "tool_use_id": "toolu_3", "content": "error: 1 test failed", "is_error": true}]), + "2026-10-02T10:00:30.000Z", + ), + line( + "assistant", + json!([{"type": "text", "text": "Renamed old_helper to new_helper; one test still fails."}]), + "2026-10-02T10:00:40.000Z", + ), + ]; + let transcript = claude.join(format!("projects/p/{chat}.jsonl")); + std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); + std::fs::write(&transcript, lines.iter().map(|l| format!("{l}\n")).collect::()).unwrap(); + backdate(&transcript); + let (hub, mut c) = super::fork_through::claude_setup(&claude, &[]).await; + + let s = c.request(method::SESSION_NEW, adopt("claude", &chat)).await.unwrap(); + let id = s["sessionId"].as_str().unwrap().to_owned(); + let events = replayed(&hub, &id); + let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect(); + assert_eq!( + kinds, + [ + "turn_started", + "user_message", + "tool_call", + "tool_call_update", + "tool_call", + "tool_call_update", + "tool_call", + "tool_call_update", + "agent_message_chunk", + "turn_end", + "turn_result" + ], + "{events:#?}" + ); + let calls: Vec<&Value> = + events.iter().filter(|e| e.kind == "tool_call").map(update_of).collect(); + assert_eq!(calls[0]["kind"], "execute"); + assert_eq!(calls[0]["rawInput"]["command"], "rg -n old_helper"); + assert_eq!(calls[1]["kind"], "edit"); + assert_eq!(calls[1]["locations"], json!([{"path": file}])); + let done: Vec<&Value> = + events.iter().filter(|e| e.kind == "tool_call_update").map(update_of).collect(); + assert_eq!(done[0]["status"], "completed"); + assert_eq!(done[0].pointer("/content/0/content/text"), Some(&json!("src/lib.rs:3:fn old_helper() {}"))); + assert_eq!( + done[1]["content"], + json!([{"type": "diff", "path": file, "oldText": "fn old_helper() {}", "newText": "fn new_helper() {}"}]) + ); + assert_eq!(done[2]["status"], "failed"); + assert_eq!( + update_of(&events[8]).pointer("/content/text"), + Some(&json!("Renamed old_helper to new_helper; one test still fails.")) + ); + let _ = std::fs::remove_dir_all(root); +} diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs index c9c88ecc1965..cf279e0105d8 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs @@ -121,7 +121,7 @@ async fn a_fork_through_the_latest_turn_opens_a_new_session_and_an_earlier_turn_ /// A hub whose default agent is a Claude stdio profile keeping Claude Code's /// own store layout under `claude` (fake_claude.py with CLAUDE_CONFIG_DIR). -async fn claude_setup(claude: &std::path::Path, env: &[(&str, &str)]) -> (Arc, TestClient) { +pub(super) async fn claude_setup(claude: &std::path::Path, env: &[(&str, &str)]) -> (Arc, TestClient) { let fake_claude = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_claude.py"); let mut env: BTreeMap = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); diff --git a/webviews/test/agent-pane-adopted-tool-calls.test.ts b/webviews/test/agent-pane-adopted-tool-calls.test.ts new file mode 100644 index 000000000000..8e9aeac78a7e --- /dev/null +++ b/webviews/test/agent-pane-adopted-tool-calls.test.ts @@ -0,0 +1,147 @@ +// l10n-allow-file: test fixture prompts and replies, not shipped UI. +// A reopened Codex chat shows each turn's work (hq#2569 item 3): the turn that ran three commands +// and edited two files opens its "Worked for 1m 32s" line to those calls, grouped as the pane +// groups a live turn's ("Ran 3 commands", "Edited 2 files +4 -1"), and each command opens to its +// line. +// +// Before: adopting a Codex chat replayed only the prompt and the final answer, so the turn showed +// the answer and a "Worked for 1m 32s" footer that opened nothing. +// +// The records are acpmux's own replay of a Codex rollout (`adopt_replays_a_codex_turns_commands_and_file_edits` +// in cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs writes the fixture). The page is the +// pane's dev entry with its browser host, talking JSON-RPC to a stand-in acpmux serving them. +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("agent-pane-adopted-tool-calls.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("agent-pane-adopted-tool-calls: skipping (run `bunx playwright install chromium`)"); + describe.skip("adopted tool calls", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + + const CHAT = "0190a000-0000-7000-8000-0000000000f7"; + const ANSWER = "Bumped demo to 1.3.0"; + const replay = (await Bun.file(path.join(import.meta.dir, "fixtures/adopted-codex-turn.events.json")).json()) as { + seq: number; + at: number; + dir: string; + kind: string; + msg: unknown; + }[]; + const events = replay.map((record) => ({ sessionId: CHAT, ...record })); + const lastSeq = events.at(-1)!.seq; + const session = { + sessionId: CHAT, + cwd: "/home/dev/demo", + title: "Bump the version", + harness: "codex", + family: "codex", + status: "idle", + turnCount: 1, + updatedAt: events.at(-1)!.at, + }; + function answer(method: string): unknown { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + case "_acpmux/watch": + return { sessions: [session] }; + case "_acpmux/attach": + return { session, lastSeq, events }; + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq }; + default: + return null; + } + } + + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + let browser: Browser | undefined; + const url = () => { + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + return `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${CHAT}`; + }; + const open = async (): Promise => { + const page = await browser!.newPage({ viewport: { width: 900, height: 900 } }); + await page.goto(url()); + await page.getByText(ANSWER).waitFor(); + return page; + }; + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string }; + if (message.id !== undefined) + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/"); + browser = await chromium.launch({ headless: true }); + // A cold dev server's first load can stall in CI: load it here, again if it stalls. + for (let attempt = 1; ; attempt++) { + const warm = await browser.newPage(); + try { + await warm.goto(url()); + await warm.getByText(ANSWER).waitFor({ timeout: 25_000 }); + await warm.close(); + break; + } catch (error) { + await warm.close(); + if (attempt === 3) throw error; + } + } + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + test("the adopted turn's Worked for opens to its commands and edited files", async () => { + const page = await open(); + const worked = page.locator("button.cv-worked"); + await expect(worked.textContent()).resolves.toBe("Worked for 1m 32s"); + expect(await worked.getAttribute("aria-expanded")).toBe("false"); + await worked.click(); + expect(await worked.getAttribute("aria-expanded")).toBe("true"); + + // The settled run's line, then its groups, as a live turn's run settles. + await page.getByRole("button", { name: "Edited files, ran commands" }).click(); + const commands = page.locator("button.cv-tool", { hasText: "Ran 3 commands" }); + const edits = page.locator("button.cv-tool", { hasText: "Edited 2 files" }); + await commands.waitFor(); + await expect(edits.textContent()).resolves.toBe("Edited 2 files+4-1"); + + await commands.click(); + const lines = await page.locator(".cv-command__line").allTextContents(); + expect(lines).toEqual(["git status --short", "grep -n '^version' Cargo.toml", "sed -n 1,8p CHANGELOG.md"]); + // The answer stays outside the fold, after the work. + await page.getByText(ANSWER).waitFor(); + await page.close(); + }); +}); diff --git a/webviews/test/fixtures/adopted-codex-turn.events.json b/webviews/test/fixtures/adopted-codex-turn.events.json new file mode 100644 index 000000000000..74c11ea85439 --- /dev/null +++ b/webviews/test/fixtures/adopted-codex-turn.events.json @@ -0,0 +1,71 @@ +[ + { + "at": 1790931600000, + "dir": "mux", + "kind": "turn_started", + "msg": { + "client": "adopt", + "control": "local", + "prompt": "Bump the version to 1.3.0 and note it in the changelog", + "promptId": "adopted-prompt-0", + "turnId": "adopted-0" + }, + "seq": 1 + }, + { + "at": 1790931600000, + "dir": "mux", + "kind": "user_message", + "msg": { + "client": "adopt", + "promptId": "adopted-prompt-0", + "text": "Bump the version to 1.3.0 and note it in the changelog", + "turnId": "adopted-0" + }, + "seq": 2 + }, + { + "at": 1790931692000, + "dir": "in", + "kind": "agent_message_chunk", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "content": { + "text": "Bumped demo to 1.3.0 and added a 1.3.0 entry to CHANGELOG.md.", + "type": "text" + }, + "sessionUpdate": "agent_message_chunk" + } + } + }, + "seq": 3 + }, + { + "at": 1790931692000, + "dir": "mux", + "kind": "turn_end", + "msg": { + "stopReason": "end_turn", + "turnId": "adopted-0", + "turnSeq": 1 + }, + "seq": 4 + }, + { + "at": 1790931692000, + "dir": "mux", + "kind": "turn_result", + "msg": { + "promptId": "adopted-prompt-0", + "status": "completed", + "stopReason": "end_turn", + "turnId": "adopted-0", + "turnSeq": 1 + }, + "seq": 5 + } +] From 132d96dd58380fb7d004993f82dd4b91422c5181 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 14:10:06 -0700 Subject: [PATCH 02/12] acpmux: a reopened Codex or Claude chat shows each turn's tool calls Adopting a chat replayed each past turn as its prompt and final answer only, so a turn that ran commands and edited files showed the answer and a Worked for footer that opened nothing (hq#2569 item 3). adopt_replay now also reads the turn's tool calls (adopt_tools.rs): Codex item_completed commands, file changes, MCP calls and web searches (and the older exec_command_end and patch events), and Claude Code tool_use with its tool_result through the live path's own tool_call builder. replay_adopted appends each as a tool_call and its settling tool_call_update before the answer, so the pane folds them under Worked for as it does a live turn's. A file change keeps each hunk as a diff and names each file with its +/- counts, for the edited-files card. The settled run's summary counts edited files rather than edit calls. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/adopt_replay.rs | 104 +++- cmux-tui/crates/acpmux/src/adopt_tools.rs | 489 ++++++++++++++++++ .../crates/acpmux/src/claude_stdio/inbound.rs | 24 +- .../crates/acpmux/src/claude_stdio/mod.rs | 24 + cmux-tui/crates/acpmux/src/hub/adoption.rs | 26 +- cmux-tui/crates/acpmux/src/lib.rs | 1 + .../acpmux/conversation/toolRunSummary.ts | 24 +- .../fixtures/adopted-codex-turn.events.json | 240 ++++++++- 8 files changed, 874 insertions(+), 58 deletions(-) create mode 100644 cmux-tui/crates/acpmux/src/adopt_tools.rs diff --git a/cmux-tui/crates/acpmux/src/adopt_replay.rs b/cmux-tui/crates/acpmux/src/adopt_replay.rs index 60882aa3b8ea..4fb603de2ee5 100644 --- a/cmux-tui/crates/acpmux/src/adopt_replay.rs +++ b/cmux-tui/crates/acpmux/src/adopt_replay.rs @@ -3,8 +3,9 @@ //! Adopting resumes a Claude Code or Codex conversation in a new acpmux session (`adopt.rs`). //! Claude Code's `--resume` replays nothing to the client, so the pane showed an empty chat. This //! reads the harness's own transcript (the file `adopt::find` found) into turns: each person -//! prompt and the assistant text that answered it. `Hub::replay_adopted` appends them as acpmux -//! history (`turn_started`, `user_message`, `agent_message_chunk`, `turn_end`) before the session +//! prompt, the tool calls the turn made (`adopt_tools.rs`) and the assistant text that answered +//! it. `Hub::replay_adopted` appends them as acpmux history (`turn_started`, `user_message`, +//! `tool_call` and `tool_call_update`, `agent_message_chunk`, `turn_end`) before the session //! continues, the same records a live turn leaves, so every client draws them. use std::io::{BufRead, BufReader, Seek, SeekFrom}; @@ -12,10 +13,14 @@ use std::path::Path; use serde_json::Value; -/// One past exchange: the person's prompt and the assistant's answer text. +use crate::adopt_tools::{PastTool, ToolResult}; + +/// One past exchange: the person's prompt, the tool calls it led to, in order, and the +/// assistant's answer text. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct PastTurn { pub prompt: String, + pub tools: Vec, pub answer: String, /// When the prompt was sent and the answer last written (ms since the epoch), when recorded. pub prompt_at: Option, @@ -25,6 +30,8 @@ pub struct PastTurn { /// At most this many turns (the newest) and this much text per answer: a long chat stays cheap. const MAX_TURNS: usize = 200; const MAX_ANSWER: usize = 20_000; +/// At most this many tool calls per turn (the first). +const MAX_TOOLS: usize = 400; /// A transcript bigger than this is read for its newest part only. const MAX_BYTES: u64 = 64 << 20; @@ -45,6 +52,9 @@ pub fn read(family: &str, file: &Path) -> Vec { // Current Codex writes no event messages: from its first annotated item on (a chat begun on // an older Codex and resumed on a current one), items are the turns. let mut current = false; + // Current Codex records each finished tool call as an `item_completed` event; older Codex + // wrote `exec_command_end` and patch events, read until the first such item. + let mut tool_items = false; for (index, line) in BufReader::new(f).lines().map_while(Result::ok).enumerate() { if skip_first && index == 0 { continue; @@ -54,14 +64,21 @@ pub fn read(family: &str, file: &Path) -> Vec { match family { "codex" => { current = current || has_item_kinds(&record); - if let Some(step) = codex_step(&record) { + if let Some((tool, item)) = crate::adopt_tools::codex_tool(&record, at) { + tool_items = tool_items || item; + if item || !tool_items { + // With the turns that are being read: event messages, else items. + let into = if current || !turns.is_empty() { &mut turns } else { &mut items }; + add(into, tool.map_or_else(Step::ToolResult, Step::Tool), at); + } + } else if let Some(step) = codex_step(&record) { add(&mut turns, step, at); } else if let Some(step) = codex_item_step(&record) { add(if current { &mut turns } else { &mut items }, step, at); } } _ => { - if let Some(step) = claude_step(&record) { + for step in claude_steps(&record, at) { add(&mut turns, step, at); } } @@ -78,17 +95,35 @@ pub fn read(family: &str, file: &Path) -> Vec { enum Step { Prompt(String), Answer(String), + Tool(PastTool), + ToolResult(ToolResult), } -/// A prompt starts a turn; an answer adds to the last one, capped at MAX_ANSWER. +/// A prompt starts a turn; an answer adds to the last one, capped at MAX_ANSWER; a tool call +/// joins the last one (once: a call recorded again replaces it), and a result settles its call. fn add(turns: &mut Vec, step: Step, at: Option) { match step { Step::Prompt(text) => turns.push(PastTurn { prompt: text, + tools: Vec::new(), answer: String::new(), prompt_at: at, answer_at: at, }), + Step::Tool(tool) => { + let Some(turn) = turns.last_mut() else { return }; + if let Some(known) = turn.tools.iter_mut().find(|t| t.id == tool.id) { + *known = tool; + } else if turn.tools.len() < MAX_TOOLS { + turn.tools.push(tool); + } + } + Step::ToolResult(result) => { + let Some(turn) = turns.last_mut() else { return }; + if let Some(tool) = turn.tools.iter_mut().rev().find(|t| t.id == result.id) { + tool.settle(&result, at); + } + } Step::Answer(text) => { let Some(turn) = turns.last_mut() else { return }; turn.answer_at = at.or(turn.answer_at); @@ -108,34 +143,49 @@ fn add(turns: &mut Vec, step: Step, at: Option) { } /// Claude Code: `{"type":"user","message":{"content":...}}` (a typed prompt: a string or text -/// blocks; tool results and meta lines are not prompts) and `{"type":"assistant",...}` text blocks. -fn claude_step(record: &Value) -> Option { +/// blocks; tool results settle their calls, and meta lines are not prompts) and +/// `{"type":"assistant",...}` text and `tool_use` blocks. +fn claude_steps(record: &Value, at: Option) -> Vec { let flag = |key: &str| record.get(key).and_then(Value::as_bool) == Some(true); if flag("isSidechain") || flag("isMeta") || flag("isCompactSummary") { - return None; + return Vec::new(); } - let content = record.pointer("/message/content")?; - match record.get("type").and_then(Value::as_str)? { - "user" => { - // A tool result is not a prompt, also beside text (as `hub::fork_at::is_prompt`). - if content.as_array().is_some_and(|blocks| { - blocks.iter().any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")) - }) { - return None; - } - let text = text_of(content)?; - let start = text.trim_start(); - // Command and hook echoes and interruption markers are not prompts. - let echo = [" + { + crate::adopt_tools::claude_tool_results(content) + .into_iter() + .map(Step::ToolResult) + .collect() } - "assistant" => text_of(content).map(Step::Answer), - _ => None, + Some("assistant") => { + let mut steps: Vec = crate::adopt_tools::claude_tool_uses(content, at) + .into_iter() + .map(Step::Tool) + .collect(); + steps.extend(text_of(content).map(Step::Answer)); + steps + } + Some("user") => claude_prompt(content).into_iter().collect(), + _ => Vec::new(), } } +/// A Claude Code prompt: a user message's text, unless it echoes a command or a hook or marks +/// an interruption. +fn claude_prompt(content: &Value) -> Option { + let text = text_of(content)?; + let start = text.trim_start(); + let echo = [" Option { if record.get("type").and_then(Value::as_str) != Some("event_msg") { diff --git a/cmux-tui/crates/acpmux/src/adopt_tools.rs b/cmux-tui/crates/acpmux/src/adopt_tools.rs new file mode 100644 index 000000000000..cdd788538348 --- /dev/null +++ b/cmux-tui/crates/acpmux/src/adopt_tools.rs @@ -0,0 +1,489 @@ +//! The tool calls of an adopted chat's past turns (hq#2569 item 3), read from the harness's own +//! transcript (`adopt_replay.rs`) as the ACP tool calls a live turn sends: a Codex command, file +//! change, MCP call or web search, and a Claude Code `tool_use` with its `tool_result`. Each is +//! the fields of its `tool_call` and of the `tool_call_update` that settles it, so the pane draws +//! a reopened turn's work as it drew it live ("Ran 3 commands", "Edited 2 files +4 -1"). +//! +//! The transcript is the record: no file is read, so an edit's before and after text are its +//! hunks' lines (context and removed, context and added), with each file's +/- counts. + +use serde_json::{Value, json}; + +/// One past tool call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PastTool { + pub id: String, + /// The `tool_call` fields: `kind`, `title`, `rawInput`, `locations`. + pub call: Value, + /// The `tool_call_update` fields that settle it: `status`, `content`, `rawOutput`. + pub done: Value, + /// When it started and ended (ms since the epoch), when recorded. + pub started_at: Option, + pub ended_at: Option, +} + +/// A tool call's result, found after the call (Claude Code's `tool_result`, Codex's older +/// `patch_apply_end`): its `status`, and `content` unless the call already carries its own (an +/// edit's diff) and did not fail. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolResult { + pub id: String, + pub done: Value, +} + +impl PastTool { + /// Settles the call with `result` at `at`. + pub fn settle(&mut self, result: &ToolResult, at: Option) { + let failed = result.done["status"] == "failed"; + if let (Some(done), Some(update)) = (self.done.as_object_mut(), result.done.as_object()) { + for (key, value) in update { + if key != "content" || failed || !done.contains_key("content") { + done.insert(key.clone(), value.clone()); + } + } + } + self.ended_at = at.or(self.ended_at); + } +} + +/// The most of a call's output kept, and the most of one side of an edit's text. +const MAX_OUTPUT: usize = 8_000; +const MAX_DIFF_TEXT: usize = 64_000; + +fn s<'a>(v: &'a Value, key: &str) -> Option<&'a str> { + v.get(key).and_then(Value::as_str) +} + +/// `text` cut to at most `max` bytes, on a character boundary. +fn capped(text: &str, max: usize) -> String { + let mut cut = text.len().min(max); + while !text.is_char_boundary(cut) { + cut -= 1; + } + text[..cut].to_owned() +} + +fn text_content(text: &str) -> Value { + json!({"type": "content", "content": {"type": "text", "text": capped(text, MAX_OUTPUT)}}) +} + +fn file_name(path: &str) -> &str { + path.rsplit('/').find(|part| !part.is_empty()).unwrap_or(path) +} + +fn first_line(text: &str) -> String { + text.lines().next().unwrap_or("").chars().take(160).collect() +} + +/// A Codex duration (`{secs, nanos}`) in ms. +fn duration_ms(value: Option<&Value>) -> Option { + let value = value?; + let secs = value.get("secs").and_then(Value::as_u64)?; + let nanos = value.get("nanos").and_then(Value::as_u64).unwrap_or(0); + Some(secs * 1000 + nanos / 1_000_000) +} + +/// A Codex item status as an ACP one. +fn status(item: &Value) -> &'static str { + match s(item, "status") { + Some("failed" | "declined" | "interrupted") => "failed", + _ => "completed", + } +} + +/// The script a shell wrapper runs (`["/bin/zsh", "-lc", "git status"]` is `git status`), else +/// the command line as it reads. +fn shell_command(command: &Value) -> Option { + if let Some(line) = command.as_str() { + return Some(line.to_owned()); + } + let argv: Vec<&str> = command.as_array()?.iter().filter_map(Value::as_str).collect(); + if let [program, flag, script] = argv.as_slice() { + let shell = matches!(file_name(program), "sh" | "bash" | "zsh" | "dash" | "fish"); + if shell && matches!(*flag, "-c" | "-lc") { + return Some((*script).to_owned()); + } + } + (!argv.is_empty()).then(|| argv.join(" ")) +} + +/// A Codex command (`CommandExecution`, or an older `exec_command_end`): one read is a read, +/// one search or listing a search, anything else a command, as cmux-codex-acp draws them live. +fn codex_command(id: &str, item: &Value, at: Option) -> Option { + let script = shell_command(item.get("command")?)?; + let cwd = s(item, "cwd").map(|cwd| cwd.strip_prefix("file://").unwrap_or(cwd)); + let actions: Vec<&Value> = + item.get("parsed_cmd").and_then(Value::as_array).into_iter().flatten().collect(); + let path = actions.first().and_then(|a| s(a, "path")).map(str::to_owned); + let (kind, title) = match actions.as_slice() { + [a] if s(a, "type") == Some("read") => { + ("read", format!("Read {}", s(a, "name").or(path.as_deref()).unwrap_or(&script))) + } + [a] if s(a, "type") == Some("search") => { + ("search", s(a, "query").map_or_else(|| first_line(&script), |q| format!("Search {q}"))) + } + [a] if s(a, "type") == Some("list_files") => { + ("search", format!("List {}", path.as_deref().unwrap_or("."))) + } + _ => ("execute", first_line(&script)), + }; + let mut call = json!({"kind": kind, "title": title, "rawInput": {"command": script, "cwd": cwd}}); + if kind != "execute" + && let Some(path) = path + { + call["locations"] = json!([{"path": path}]); + } + let output = ["aggregated_output", "formatted_output", "stdout"] + .iter() + .find_map(|key| s(item, key)) + .unwrap_or(""); + let exit_code = item.get("exit_code").and_then(Value::as_i64); + let took = duration_ms(item.get("duration")); + let failed = status(item) == "failed" || exit_code.is_some_and(|code| code != 0); + let mut done = json!({ + "status": if failed { "failed" } else { "completed" }, + "rawOutput": {"exit_code": exit_code, "duration_ms": took}, + }); + if !output.is_empty() { + done["content"] = json!([text_content(output)]); + } + Some(PastTool { + id: id.to_owned(), + call, + done, + started_at: at.map(|end| end.saturating_sub(took.unwrap_or(0))), + ended_at: at, + }) +} + +/// One file of a change: `add`, `update` or `delete`, its text (an added or deleted file's +/// content, an update's unified diff) and where a move put it. +struct Change { + path: String, + kind: String, + text: String, + move_path: Option, +} + +/// A change's files: current Codex's `{path: {type, unified_diff|content, move_path}}`, older +/// Codex's `{path: {"update": {unified_diff, move_path}}}`, or app-server's `[{path, kind, diff}]`. +fn changes(value: &Value) -> Vec { + let text = |v: &Value| { + ["unified_diff", "content", "diff"].iter().find_map(|k| s(v, k)).unwrap_or("").to_owned() + }; + let move_path = |v: &Value| s(v, "move_path").map(str::to_owned); + if let Some(list) = value.as_array() { + return list + .iter() + .filter_map(|c| { + let kind = c.pointer("/kind/type").and_then(Value::as_str).unwrap_or("update"); + Some(Change { + path: s(c, "path")?.to_owned(), + kind: kind.to_owned(), + text: text(c), + move_path: c.pointer("/kind/move_path").and_then(Value::as_str).map(str::to_owned), + }) + }) + .collect(); + } + let Some(map) = value.as_object() else { return Vec::new() }; + map.iter() + .map(|(path, c)| { + let (kind, body) = match s(c, "type") { + Some(kind) => (kind.to_owned(), c), + None => match c.as_object().and_then(|o| o.iter().next()) { + Some((kind, body)) => (kind.clone(), body), + None => ("update".to_owned(), c), + }, + }; + Change { path: path.clone(), kind, text: text(body), move_path: move_path(body) } + }) + .collect() +} + +/// One hunk of a unified diff: its old start line, and the lines it expects and leaves. +struct Hunk { + old_start: u64, + old: Vec, + new: Vec, + additions: u64, + deletions: u64, +} + +fn hunks(diff: &str) -> Vec { + let mut out: Vec = Vec::new(); + for line in diff.strip_suffix('\n').unwrap_or(diff).split('\n') { + let header = if line == "@@" { Some("") } else { line.strip_prefix("@@ ") }; + if let Some(rest) = header { + let old_start = rest + .split(' ') + .find_map(|w| w.strip_prefix('-')) + .and_then(|range| range.split(',').next()?.parse().ok()) + .unwrap_or(1); + out.push(Hunk { old_start, old: Vec::new(), new: Vec::new(), additions: 0, deletions: 0 }); + continue; + } + // File headers ("--- a", "+++ b") come before the first hunk only. + let Some(hunk) = out.last_mut() else { continue }; + match line.chars().next() { + Some('-') => { + hunk.old.push(line[1..].to_owned()); + hunk.deletions += 1; + } + Some('+') => { + hunk.new.push(line[1..].to_owned()); + hunk.additions += 1; + } + Some(' ') => { + hunk.old.push(line[1..].to_owned()); + hunk.new.push(line[1..].to_owned()); + } + // A blank context line whose leading space was trimmed. + None => { + hunk.old.push(String::new()); + hunk.new.push(String::new()); + } + // "\ No newline at end of file". + Some(_) => {} + } + } + out +} + +fn joined(lines: &[String]) -> String { + let mut text = lines.join("\n"); + if !lines.is_empty() { + text.push('\n'); + } + text +} + +/// A Codex file change (`FileChange`, or an older patch event): one edit over its files, each +/// hunk an ACP `diff` of its lines, the call's input naming each file with its +/- counts (the +/// edited-files card's numbers). A hunk too big to draw is shown as the diff's text. +fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> Option { + let files = changes(value); + if files.is_empty() { + return None; + } + let mut content = Vec::new(); + let mut locations = Vec::new(); + let mut summary = Vec::new(); + for change in &files { + let target = change.move_path.as_deref().unwrap_or(&change.path); + let (additions, deletions) = match change.kind.as_str() { + "add" => { + content.push(json!({"type": "diff", "path": target, "newText": change.text})); + locations.push(json!({"path": target})); + (change.text.lines().count() as u64, 0) + } + "delete" => { + content.push(json!({"type": "diff", "path": target, "oldText": change.text, "newText": ""})); + locations.push(json!({"path": target})); + (0, change.text.lines().count() as u64) + } + _ => { + let parts = hunks(&change.text); + if parts.is_empty() && !change.text.is_empty() { + content.push(text_content(&format!("{target}\n{}", change.text))); + } + for hunk in &parts { + content.push(json!({"type": "diff", "path": target, "oldText": joined(&hunk.old), "newText": joined(&hunk.new)})); + } + // A location's line numbers its file's diffs: only one hunk starts there. + locations.push(match parts.as_slice() { + [one] => json!({"path": target, "line": one.old_start}), + _ => json!({"path": target}), + }); + parts.iter().fold((0, 0), |(a, d), h| (a + h.additions, d + h.deletions)) + } + }; + let mut entry = json!({"path": target, "type": change.kind, "additions": additions, "deletions": deletions}); + if change.move_path.is_some() { + entry["from"] = json!(change.path); + } + summary.push(entry); + } + // An edit too big to draw shows its files' diffs as text. + let mut content = Value::Array(content); + if oversize(&content) { + content = files + .iter() + .map(|c| text_content(&format!("{}\n{}", c.move_path.as_deref().unwrap_or(&c.path), c.text))) + .collect(); + } + let title = match files.as_slice() { + [one] => { + let verb = match one.kind.as_str() { + "add" => "Write", + "delete" => "Delete", + _ => "Edit", + }; + format!("{verb} {}", file_name(one.move_path.as_deref().unwrap_or(&one.path))) + } + many => format!("Edit {} files", many.len()), + }; + Some(PastTool { + id: id.to_owned(), + call: json!({"kind": "edit", "title": title, "locations": locations, "rawInput": {"changes": summary}}), + done: json!({"status": status, "content": content}), + started_at: at, + ended_at: at, + }) +} + +/// MCP or tool result blocks as ACP content: their text. +fn result_text(blocks: Option<&Value>) -> Vec { + match blocks { + Some(Value::String(text)) if !text.is_empty() => vec![text_content(text)], + Some(Value::Array(blocks)) => { + let text: Vec<&str> = blocks.iter().filter_map(|b| s(b, "text")).collect(); + if text.is_empty() { Vec::new() } else { vec![text_content(&text.join("\n"))] } + } + _ => Vec::new(), + } +} + +/// A Codex MCP call. +fn codex_mcp(id: &str, item: &Value, at: Option) -> PastTool { + let server = s(item, "server").unwrap_or("mcp"); + let tool = s(item, "tool").unwrap_or("tool"); + let took = duration_ms(item.get("duration")); + let failed = status(item) == "failed" + || item.pointer("/result/isError").and_then(Value::as_bool) == Some(true); + PastTool { + id: id.to_owned(), + call: json!({"kind": "other", "title": format!("{server}: {tool}"), "rawInput": item.get("arguments")}), + done: json!({ + "status": if failed { "failed" } else { "completed" }, + "content": result_text(item.pointer("/result/content")), + }), + started_at: at.map(|end| end.saturating_sub(took.unwrap_or(0))), + ended_at: at, + } +} + +/// What a Codex rollout record says a tool did, at `at`. `items`: the record is current Codex's +/// `item_completed` (`Some(true)`), or an older event (`Some(false)`); None when neither. +pub fn codex_tool(record: &Value, at: Option) -> Option<(Result, bool)> { + if s(record, "type") != Some("event_msg") { + return None; + } + let payload = record.get("payload")?; + match s(payload, "type")? { + "item_completed" => { + let item = payload.get("item")?; + let id = s(item, "id")?; + let tool = match s(item, "type")? { + "CommandExecution" => codex_command(id, item, at)?, + "FileChange" => codex_file_change(id, item.get("changes")?, status(item), at)?, + "McpToolCall" => codex_mcp(id, item, at), + "Extension" if s(item, "kind") == Some("web.search") => { + let query = s(item, "query").unwrap_or(""); + PastTool { + id: id.to_owned(), + call: json!({"kind": "fetch", "title": if query.is_empty() { "Web search".to_owned() } else { format!("Search {query}") }, "rawInput": {"query": query}}), + done: json!({"status": status(item)}), + started_at: at, + ended_at: at, + } + } + _ => return None, + }; + Some((Ok(tool), true)) + } + "exec_command_end" => Some((Ok(codex_command(s(payload, "call_id")?, payload, at)?), false)), + "patch_apply_begin" => { + let id = s(payload, "call_id")?; + Some((Ok(codex_file_change(id, payload.get("changes")?, "completed", at)?), false)) + } + "patch_apply_end" => { + let id = s(payload, "call_id")?; + let state = if payload.get("success").and_then(Value::as_bool) == Some(false) { + "failed" + } else { + "completed" + }; + if let Some(tool) = payload.get("changes").and_then(|c| codex_file_change(id, c, state, at)) { + return Some((Ok(tool), false)); + } + let mut done = json!({"status": state}); + if state == "failed" + && let Some(text) = s(payload, "stderr").filter(|t| !t.is_empty()) + { + done["content"] = json!([text_content(text)]); + } + Some((Err(ToolResult { id: id.to_owned(), done }), false)) + } + "mcp_tool_call_end" => { + let id = s(payload, "call_id")?; + let invocation = payload.get("invocation").cloned().unwrap_or(Value::Null); + let result = payload.get("result"); + let ok = result.and_then(|r| r.get("Ok")); + let mut item = json!({ + "server": invocation.get("server"), + "tool": invocation.get("tool"), + "arguments": invocation.get("arguments"), + "duration": payload.get("duration"), + "status": if ok.is_some() { "completed" } else { "failed" }, + }); + if let Some(ok) = ok { + item["result"] = ok.clone(); + } + Some((Ok(codex_mcp(id, &item, at)), false)) + } + _ => None, + } +} + +/// Claude Code's `tool_use` blocks in an assistant message, as the calls a live turn sends +/// (`claude_stdio::tool_call_fields`), not yet settled: a call whose result never came (a turn +/// cut off) stays failed. An edit's diffs settle it with its result; one too big to draw is +/// left out. The todo list is the turn's plan, not a call. +pub fn claude_tool_uses(content: &Value, at: Option) -> Vec { + content + .as_array() + .into_iter() + .flatten() + .filter(|b| s(b, "type") == Some("tool_use")) + .filter_map(|block| { + let id = s(block, "id")?; + let name = s(block, "name").filter(|name| *name != "TodoWrite")?; + let input = block.get("input").cloned().unwrap_or(Value::Null); + let mut call = crate::claude_stdio::tool_call_fields(name, &input); + let diffs = call.as_object_mut().and_then(|c| c.remove("content")); + let mut done = json!({"status": "failed"}); + if let Some(diffs) = diffs.filter(|d| !oversize(d)) { + done["content"] = diffs; + } + Some(PastTool { id: id.to_owned(), call, done, started_at: at, ended_at: None }) + }) + .collect() +} + +/// Whether some diff in `content` has a side too big to draw. +fn oversize(content: &Value) -> bool { + content.as_array().into_iter().flatten().any(|c| { + ["oldText", "newText"].iter().any(|k| s(c, k).is_some_and(|t| t.len() > MAX_DIFF_TEXT)) + }) +} + +/// Claude Code's `tool_result` blocks in a user message. +pub fn claude_tool_results(content: &Value) -> Vec { + content + .as_array() + .into_iter() + .flatten() + .filter(|b| s(b, "type") == Some("tool_result")) + .filter_map(|block| { + let failed = block.get("is_error").and_then(Value::as_bool) == Some(true); + Some(ToolResult { + id: s(block, "tool_use_id")?.to_owned(), + done: json!({ + "status": if failed { "failed" } else { "completed" }, + "content": result_text(block.get("content")), + }), + }) + }) + .collect() +} diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs b/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs index 72cd49ef1eaf..bf889b2aef5c 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/inbound.rs @@ -206,16 +206,10 @@ impl Translator { }; self.background_shells.lock().await.insert(id.to_owned(), shell); } - let mut call = json!({ - "sessionUpdate": "tool_call", - "toolCallId": c.get("id"), - "title": tool_title(name, &input), - "kind": tool_kind(name), - "status": "in_progress", - "rawInput": input, - "_meta": {"claude": {"tool": name}} - }); - with_edit(&mut call, name, &input); + let mut call = tool_call_fields(name, &input); + call["sessionUpdate"] = json!("tool_call"); + call["toolCallId"] = c.get("id").cloned().unwrap_or(Value::Null); + call["status"] = json!("in_progress"); if let (Some(diffs), Some(id)) = (call.get("content"), c.get("id").and_then(Value::as_str)) { @@ -804,16 +798,6 @@ impl Translator { } } -/// Adds a file edit's diff and location to its ACP tool call. -fn with_edit(call: &mut Value, name: &str, input: &Value) { - if let Some(diffs) = edit_diffs(name, input) { - call["content"] = diffs; - if let Some(locations) = tool_locations(input) { - call["locations"] = locations; - } - } -} - /// TodoWrite's `todos` as an ACP `plan` update: each item's text and status /// (pending, in_progress or completed, as ACP names them). Nil for input /// that is not a todo list. diff --git a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs index 07e9e40cbebb..2c68fcdfd62b 100644 --- a/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs +++ b/cmux-tui/crates/acpmux/src/claude_stdio/mod.rs @@ -547,6 +547,30 @@ impl Translator { } } +/// A Claude Code `tool_use` as the fields of the ACP `tool_call` a turn sends for it (without +/// its id and status): kind, title, input, and an edit's diffs and file. A live turn +/// (`inbound.rs`) and an adopted chat's past turns (`adopt_tools.rs`) both draw calls from it. +pub(crate) fn tool_call_fields(name: &str, input: &Value) -> Value { + let mut call = json!({ + "title": tool_title(name, input), + "kind": tool_kind(name), + "rawInput": input, + "_meta": {"claude": {"tool": name}} + }); + with_edit(&mut call, name, input); + call +} + +/// Adds a file edit's diff and location to its ACP tool call. +fn with_edit(call: &mut Value, name: &str, input: &Value) { + if let Some(diffs) = edit_diffs(name, input) { + call["content"] = diffs; + if let Some(locations) = tool_locations(input) { + call["locations"] = locations; + } + } +} + fn tool_kind(name: &str) -> &'static str { match name { "Read" | "Glob" | "Grep" | "NotebookRead" => "read", diff --git a/cmux-tui/crates/acpmux/src/hub/adoption.rs b/cmux-tui/crates/acpmux/src/hub/adoption.rs index 8d0ead882d34..4dbf4e8babea 100644 --- a/cmux-tui/crates/acpmux/src/hub/adoption.rs +++ b/cmux-tui/crates/acpmux/src/hub/adoption.rs @@ -432,12 +432,16 @@ impl Hub { } self.save_meta(session); let agent_sid = session.meta().agent_session_id.unwrap_or_default(); + let update = |fields: Value| json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": agent_sid, "update": fields}}); for (i, turn) in turns.iter().enumerate() { let turn_id = format!("adopted-{i}"); let prompt_id = format!("adopted-prompt-{i}"); // Each record keeps the transcript's time, so a fork through it cuts Claude's - // transcript there (`fork_at.rs`) and the turn shows its real time. - let (started, ended) = (turn.prompt_at, turn.answer_at.or(turn.prompt_at)); + // transcript there (`fork_at.rs`) and the turn shows its real time. The turn ends + // with its answer, or its last tool call when that ended later. + let started = turn.prompt_at; + let answered = turn.answer_at.or(started); + let ended = turn.tools.iter().filter_map(|t| t.ended_at).chain(answered).max(); let turn_seq = self .append_at( session, @@ -457,14 +461,28 @@ impl Hub { None, started, ); + // The turn's tool calls, before its answer, as a live turn sends them: each starts + // running and its update settles it, so the pane folds them under "Worked for". + for tool in &turn.tools { + let start = tool.started_at.or(tool.ended_at).or(started); + let mut call = tool.call.clone(); + call["sessionUpdate"] = json!("tool_call"); + call["toolCallId"] = json!(tool.id); + call["status"] = json!("in_progress"); + self.append_at(session, "in", "tool_call", update(call), None, start); + let mut done = tool.done.clone(); + done["sessionUpdate"] = json!("tool_call_update"); + done["toolCallId"] = json!(tool.id); + self.append_at(session, "in", "tool_call_update", update(done), None, tool.ended_at.or(start)); + } if !turn.answer.is_empty() { self.append_at( session, "in", "agent_message_chunk", - json!({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": agent_sid, "update": {"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": turn.answer}}}}), + update(json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": turn.answer}})), None, - ended, + answered, ); } self.append_at( diff --git a/cmux-tui/crates/acpmux/src/lib.rs b/cmux-tui/crates/acpmux/src/lib.rs index aa106f8f11cf..f636fc018944 100644 --- a/cmux-tui/crates/acpmux/src/lib.rs +++ b/cmux-tui/crates/acpmux/src/lib.rs @@ -15,6 +15,7 @@ pub mod adopt; pub mod adopt_live; pub mod adopt_replay; +pub mod adopt_tools; pub mod agent; pub mod agent_displays; pub mod agent_host; diff --git a/webviews/src/agent-session/acpmux/conversation/toolRunSummary.ts b/webviews/src/agent-session/acpmux/conversation/toolRunSummary.ts index 06ac68294f2a..bef0f5b52468 100644 --- a/webviews/src/agent-session/acpmux/conversation/toolRunSummary.ts +++ b/webviews/src/agent-session/acpmux/conversation/toolRunSummary.ts @@ -48,10 +48,12 @@ export function toolRunCategories(items: readonly AcpmuxActivity[]): ToolRunCate export function toolRunSummary(t: Translate, items: readonly AcpmuxActivity[]): string { const tools = items.filter((item) => item.tool); const count = (category: ToolRunCategory) => - tools.filter((item) => { - const own = toolRunCategory(item.tool!.kind); - return own === category || (category === "read" && own === "searched"); - }).length; + category === "edited" + ? editedFiles(tools) + : tools.filter((item) => { + const own = toolRunCategory(item.tool!.kind); + return own === category || (category === "read" && own === "searched"); + }).length; const phrase = (category: ToolRunCategory) => { const one = count(category) === 1; switch (category) { @@ -74,3 +76,17 @@ export function toolRunSummary(t: Translate, items: readonly AcpmuxActivity[]): const text = toolRunCategories(items).map(phrase).join(t("toolRun.separator")); return text.charAt(0).toUpperCase() + text.slice(1); } + +/// The files a run's edits changed: one Codex change can edit several, so a call counts each +/// file its diffs and locations name, or one when it names none. +function editedFiles(tools: readonly AcpmuxActivity[]): number { + const paths = new Set(); + let unnamed = 0; + for (const { tool } of tools) { + if (toolRunCategory(tool!.kind) !== "edited") continue; + const named = [...(tool!.diffs ?? []), ...(tool!.locations ?? [])].map((file) => file.path); + if (named.length === 0) unnamed += 1; + for (const path of named) paths.add(path); + } + return paths.size + unnamed; +} diff --git a/webviews/test/fixtures/adopted-codex-turn.events.json b/webviews/test/fixtures/adopted-codex-turn.events.json index 74c11ea85439..4c09a978ee68 100644 --- a/webviews/test/fixtures/adopted-codex-turn.events.json +++ b/webviews/test/fixtures/adopted-codex-turn.events.json @@ -24,6 +24,240 @@ }, "seq": 2 }, + { + "at": 1790931605790, + "dir": "in", + "kind": "tool_call", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "kind": "execute", + "rawInput": { + "command": "git status --short", + "cwd": "/home/dev/demo" + }, + "sessionUpdate": "tool_call", + "status": "in_progress", + "title": "git status --short", + "toolCallId": "exec-1" + } + } + }, + "seq": 3 + }, + { + "at": 1790931606000, + "dir": "in", + "kind": "tool_call_update", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "rawOutput": { + "duration_ms": 210, + "exit_code": 0 + }, + "sessionUpdate": "tool_call_update", + "status": "completed", + "toolCallId": "exec-1" + } + } + }, + "seq": 4 + }, + { + "at": 1790931608950, + "dir": "in", + "kind": "tool_call", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "kind": "execute", + "rawInput": { + "command": "grep -n '^version' Cargo.toml", + "cwd": "/home/dev/demo" + }, + "sessionUpdate": "tool_call", + "status": "in_progress", + "title": "grep -n '^version' Cargo.toml", + "toolCallId": "exec-2" + } + } + }, + "seq": 5 + }, + { + "at": 1790931609000, + "dir": "in", + "kind": "tool_call_update", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "content": [ + { + "content": { + "text": "3:version = \"1.2.0\"\n", + "type": "text" + }, + "type": "content" + } + ], + "rawOutput": { + "duration_ms": 50, + "exit_code": 0 + }, + "sessionUpdate": "tool_call_update", + "status": "completed", + "toolCallId": "exec-2" + } + } + }, + "seq": 6 + }, + { + "at": 1790931611960, + "dir": "in", + "kind": "tool_call", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "kind": "execute", + "rawInput": { + "command": "sed -n 1,8p CHANGELOG.md", + "cwd": "/home/dev/demo" + }, + "sessionUpdate": "tool_call", + "status": "in_progress", + "title": "sed -n 1,8p CHANGELOG.md", + "toolCallId": "exec-3" + } + } + }, + "seq": 7 + }, + { + "at": 1790931612000, + "dir": "in", + "kind": "tool_call_update", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "content": [ + { + "content": { + "text": "# Changelog\n\n## 1.2.0\n- Faster startup\n", + "type": "text" + }, + "type": "content" + } + ], + "rawOutput": { + "duration_ms": 40, + "exit_code": 0 + }, + "sessionUpdate": "tool_call_update", + "status": "completed", + "toolCallId": "exec-3" + } + } + }, + "seq": 8 + }, + { + "at": 1790931670000, + "dir": "in", + "kind": "tool_call", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "kind": "edit", + "locations": [ + { + "line": 1, + "path": "/home/dev/demo/CHANGELOG.md" + }, + { + "line": 1, + "path": "/home/dev/demo/Cargo.toml" + } + ], + "rawInput": { + "changes": [ + { + "additions": 3, + "deletions": 0, + "path": "/home/dev/demo/CHANGELOG.md", + "type": "update" + }, + { + "additions": 1, + "deletions": 1, + "path": "/home/dev/demo/Cargo.toml", + "type": "update" + } + ] + }, + "sessionUpdate": "tool_call", + "status": "in_progress", + "title": "Edit 2 files", + "toolCallId": "exec-4" + } + } + }, + "seq": 9 + }, + { + "at": 1790931670000, + "dir": "in", + "kind": "tool_call_update", + "msg": { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "0199a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a60", + "update": { + "content": [ + { + "newText": "# Changelog\n\n## 1.3.0\n- Bump the version to 1.3.0\n\n## 1.2.0\n", + "oldText": "# Changelog\n\n## 1.2.0\n", + "path": "/home/dev/demo/CHANGELOG.md", + "type": "diff" + }, + { + "newText": "[package]\nname = \"demo\"\nversion = \"1.3.0\"\nedition = \"2021\"\n", + "oldText": "[package]\nname = \"demo\"\nversion = \"1.2.0\"\nedition = \"2021\"\n", + "path": "/home/dev/demo/Cargo.toml", + "type": "diff" + } + ], + "sessionUpdate": "tool_call_update", + "status": "completed", + "toolCallId": "exec-4" + } + } + }, + "seq": 10 + }, { "at": 1790931692000, "dir": "in", @@ -42,7 +276,7 @@ } } }, - "seq": 3 + "seq": 11 }, { "at": 1790931692000, @@ -53,7 +287,7 @@ "turnId": "adopted-0", "turnSeq": 1 }, - "seq": 4 + "seq": 12 }, { "at": 1790931692000, @@ -66,6 +300,6 @@ "turnId": "adopted-0", "turnSeq": 1 }, - "seq": 5 + "seq": 13 } ] From 3a9e3b6ec99c6bc80f0ab94f3ae0f8a0d3b18b84 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 14:11:55 -0700 Subject: [PATCH 03/12] acpmux: cargo fmt Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/adopt_replay.rs | 10 +++--- cmux-tui/crates/acpmux/src/adopt_tools.rs | 32 +++++++++++++---- cmux-tui/crates/acpmux/src/hub/adoption.rs | 9 ++++- .../acpmux/tests/hub_integration/adopt.rs | 34 ++++++++++++++----- 4 files changed, 64 insertions(+), 21 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/adopt_replay.rs b/cmux-tui/crates/acpmux/src/adopt_replay.rs index 4fb603de2ee5..f4db12882ee5 100644 --- a/cmux-tui/crates/acpmux/src/adopt_replay.rs +++ b/cmux-tui/crates/acpmux/src/adopt_replay.rs @@ -68,7 +68,8 @@ pub fn read(family: &str, file: &Path) -> Vec { tool_items = tool_items || item; if item || !tool_items { // With the turns that are being read: event messages, else items. - let into = if current || !turns.is_empty() { &mut turns } else { &mut items }; + let into = + if current || !turns.is_empty() { &mut turns } else { &mut items }; add(into, tool.map_or_else(Step::ToolResult, Step::Tool), at); } } else if let Some(step) = codex_step(&record) { @@ -153,9 +154,10 @@ fn claude_steps(record: &Value, at: Option) -> Vec { let Some(content) = record.pointer("/message/content") else { return Vec::new() }; match record.get("type").and_then(Value::as_str) { // A tool result is not a prompt, also beside text (as `hub::fork_at::is_prompt`). - Some("user") if content.as_array().is_some_and(|blocks| { - blocks.iter().any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")) - }) => + Some("user") + if content.as_array().is_some_and(|blocks| { + blocks.iter().any(|b| b.get("type").and_then(Value::as_str) == Some("tool_result")) + }) => { crate::adopt_tools::claude_tool_results(content) .into_iter() diff --git a/cmux-tui/crates/acpmux/src/adopt_tools.rs b/cmux-tui/crates/acpmux/src/adopt_tools.rs index cdd788538348..1bfcc3f6003e 100644 --- a/cmux-tui/crates/acpmux/src/adopt_tools.rs +++ b/cmux-tui/crates/acpmux/src/adopt_tools.rs @@ -127,7 +127,8 @@ fn codex_command(id: &str, item: &Value, at: Option) -> Option { } _ => ("execute", first_line(&script)), }; - let mut call = json!({"kind": kind, "title": title, "rawInput": {"command": script, "cwd": cwd}}); + let mut call = + json!({"kind": kind, "title": title, "rawInput": {"command": script, "cwd": cwd}}); if kind != "execute" && let Some(path) = path { @@ -181,7 +182,10 @@ fn changes(value: &Value) -> Vec { path: s(c, "path")?.to_owned(), kind: kind.to_owned(), text: text(c), - move_path: c.pointer("/kind/move_path").and_then(Value::as_str).map(str::to_owned), + move_path: c + .pointer("/kind/move_path") + .and_then(Value::as_str) + .map(str::to_owned), }) }) .collect(); @@ -220,7 +224,13 @@ fn hunks(diff: &str) -> Vec { .find_map(|w| w.strip_prefix('-')) .and_then(|range| range.split(',').next()?.parse().ok()) .unwrap_or(1); - out.push(Hunk { old_start, old: Vec::new(), new: Vec::new(), additions: 0, deletions: 0 }); + out.push(Hunk { + old_start, + old: Vec::new(), + new: Vec::new(), + additions: 0, + deletions: 0, + }); continue; } // File headers ("--- a", "+++ b") come before the first hunk only. @@ -278,7 +288,9 @@ fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> (change.text.lines().count() as u64, 0) } "delete" => { - content.push(json!({"type": "diff", "path": target, "oldText": change.text, "newText": ""})); + content.push( + json!({"type": "diff", "path": target, "oldText": change.text, "newText": ""}), + ); locations.push(json!({"path": target})); (0, change.text.lines().count() as u64) } @@ -309,7 +321,9 @@ fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> if oversize(&content) { content = files .iter() - .map(|c| text_content(&format!("{}\n{}", c.move_path.as_deref().unwrap_or(&c.path), c.text))) + .map(|c| { + text_content(&format!("{}\n{}", c.move_path.as_deref().unwrap_or(&c.path), c.text)) + }) .collect(); } let title = match files.as_slice() { @@ -392,7 +406,9 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result Some((Ok(codex_command(s(payload, "call_id")?, payload, at)?), false)), + "exec_command_end" => { + Some((Ok(codex_command(s(payload, "call_id")?, payload, at)?), false)) + } "patch_apply_begin" => { let id = s(payload, "call_id")?; Some((Ok(codex_file_change(id, payload.get("changes")?, "completed", at)?), false)) @@ -404,7 +420,9 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result &Value { #[tokio::test] async fn adopt_replays_a_codex_turns_commands_and_file_edits() { let (hub, mut c, root, project, chat) = adopt_setup().await; - let rollout = root.join(format!("codex/sessions/2026/10/02/rollout-2026-10-02T09-00-00-{chat}.jsonl")); + let rollout = + root.join(format!("codex/sessions/2026/10/02/rollout-2026-10-02T09-00-00-{chat}.jsonl")); let fixture = include_str!("../fixtures/codex-rollout-tool-calls.jsonl") .replace("{ID}", &chat) .replace("{CWD}", &project.to_string_lossy()); @@ -362,8 +363,10 @@ async fn adopt_replays_a_codex_turns_commands_and_file_edits() { assert_eq!(events[1].msg["text"], "Bump the version to 1.3.0 and note it in the changelog"); let calls: Vec<&Value> = events.iter().filter(|e| e.kind == "tool_call").map(update_of).collect(); - let commands: Vec<&str> = - calls.iter().filter_map(|u| u.pointer("/rawInput/command").and_then(Value::as_str)).collect(); + let commands: Vec<&str> = calls + .iter() + .filter_map(|u| u.pointer("/rawInput/command").and_then(Value::as_str)) + .collect(); assert_eq!( commands, ["git status --short", "grep -n '^version' Cargo.toml", "sed -n 1,8p CHANGELOG.md"] @@ -378,7 +381,8 @@ async fn adopt_replays_a_codex_turns_commands_and_file_edits() { assert_eq!(grep["status"], "completed"); assert_eq!(grep["rawOutput"]["exit_code"], 0); assert_eq!(grep.pointer("/content/0/content/text"), Some(&json!("3:version = \"1.2.0\"\n"))); - let started = events.iter().find(|e| e.kind == "tool_call" && update_of(e)["toolCallId"] == "exec-2"); + let started = + events.iter().find(|e| e.kind == "tool_call" && update_of(e)["toolCallId"] == "exec-2"); assert_eq!(done[1].at - started.unwrap().at, 50, "the call ran 50ms"); // The edit: one call over two files (in path order: Codex records them as a map), each a @@ -387,14 +391,19 @@ async fn adopt_replays_a_codex_turns_commands_and_file_edits() { assert_eq!(edit["kind"], "edit"); let cargo = project.join("Cargo.toml").to_string_lossy().into_owned(); let changelog = project.join("CHANGELOG.md").to_string_lossy().into_owned(); - assert_eq!(edit["locations"], json!([{"path": changelog, "line": 1}, {"path": cargo, "line": 1}])); + assert_eq!( + edit["locations"], + json!([{"path": changelog, "line": 1}, {"path": cargo, "line": 1}]) + ); let edit_done = update_of(done[3]); assert_eq!(edit_done["status"], "completed"); let diffs = edit_done["content"].as_array().unwrap(); assert_eq!(diffs.len(), 2, "{edit_done}"); assert_eq!(diffs[0]["type"], "diff"); assert_eq!(diffs[0]["path"], changelog.as_str()); - assert!(diffs[0]["newText"].as_str().unwrap().contains("## 1.3.0\n- Bump the version to 1.3.0")); + assert!( + diffs[0]["newText"].as_str().unwrap().contains("## 1.3.0\n- Bump the version to 1.3.0") + ); assert_eq!(diffs[1]["path"], cargo.as_str()); assert!(diffs[1]["oldText"].as_str().unwrap().contains("version = \"1.2.0\"")); assert!(diffs[1]["newText"].as_str().unwrap().contains("version = \"1.3.0\"")); @@ -446,7 +455,10 @@ fn write_pane_fixture(events: &[acpmux::store::EventRecord], chat: &str, root: & std::fs::write(path, &text).unwrap(); } else if let Ok(stored) = std::fs::read_to_string(path) { // A checkout without the webviews (a crate-only build) has nothing to check. - assert_eq!(stored, text, "the pane's fixture is stale: rerun with ACPMUX_UPDATE_FIXTURES=1"); + assert_eq!( + stored, text, + "the pane's fixture is stale: rerun with ACPMUX_UPDATE_FIXTURES=1" + ); } } @@ -505,7 +517,8 @@ async fn adopt_replays_a_claude_turns_tool_calls() { ]; let transcript = claude.join(format!("projects/p/{chat}.jsonl")); std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); - std::fs::write(&transcript, lines.iter().map(|l| format!("{l}\n")).collect::()).unwrap(); + std::fs::write(&transcript, lines.iter().map(|l| format!("{l}\n")).collect::()) + .unwrap(); backdate(&transcript); let (hub, mut c) = super::fork_through::claude_setup(&claude, &[]).await; @@ -539,7 +552,10 @@ async fn adopt_replays_a_claude_turns_tool_calls() { let done: Vec<&Value> = events.iter().filter(|e| e.kind == "tool_call_update").map(update_of).collect(); assert_eq!(done[0]["status"], "completed"); - assert_eq!(done[0].pointer("/content/0/content/text"), Some(&json!("src/lib.rs:3:fn old_helper() {}"))); + assert_eq!( + done[0].pointer("/content/0/content/text"), + Some(&json!("src/lib.rs:3:fn old_helper() {}")) + ); assert_eq!( done[1]["content"], json!([{"type": "diff", "path": file, "oldText": "fn old_helper() {}", "newText": "fn new_helper() {}"}]) From 5725a85b02d77399ce81fd837a70ab6327c4ca78 Mon Sep 17 00:00:00 2001 From: "cmux-autofix[bot]" Date: Mon, 19 Oct 2026 21:25:21 +0000 Subject: [PATCH 04/12] chore(next): PR autofix at 2c2b9a6e90ea - cargo clippy --fix cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs Autofix-Run: https://github.com/manaflow-ai/cmux/actions/runs/39325739748 --- cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs index cf279e0105d8..b568a809388d 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/fork_through.rs @@ -121,7 +121,10 @@ async fn a_fork_through_the_latest_turn_opens_a_new_session_and_an_earlier_turn_ /// A hub whose default agent is a Claude stdio profile keeping Claude Code's /// own store layout under `claude` (fake_claude.py with CLAUDE_CONFIG_DIR). -pub(super) async fn claude_setup(claude: &std::path::Path, env: &[(&str, &str)]) -> (Arc, TestClient) { +pub(super) async fn claude_setup( + claude: &std::path::Path, + env: &[(&str, &str)], +) -> (Arc, TestClient) { let fake_claude = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fake_claude.py"); let mut env: BTreeMap = env.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect(); From 68edd1d96509d23407e49b34374140bc85d0f5e7 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 19:21:44 -0700 Subject: [PATCH 05/12] acpmux: a failed Codex patch replays no edits, a replayed hunk is no whole-file Undo, a multi-file patch counts each file A reopened chat's failed or declined Codex file change keeps its files and its error text but no diffs or +/- counts: they never applied. Each replayed hunk is marked as part of its file (_meta.acpmux.fragment), and the edited-files card's Undo refuses a fragment. The folded run summary counts a diff-less edit by the files its input names (toolPaths editedPaths). Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/adopt_tools.rs | 56 +++++++++++---- .../acpmux/tests/hub_integration/adopt.rs | 70 +++++++++++++++++++ .../acpmux/conversation/toolRunSummary.ts | 8 ++- webviews/src/agent-session/acpmux/direct.ts | 1 + webviews/src/agent-session/acpmux/model.ts | 5 +- .../agent-session/acpmux/turnChanges/model.ts | 2 + .../agent-pane-adopted-tool-calls.test.ts | 69 ++++++++++++------ .../fixtures/adopted-codex-turn.events.json | 10 +++ 8 files changed, 183 insertions(+), 38 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/adopt_tools.rs b/cmux-tui/crates/acpmux/src/adopt_tools.rs index 1bfcc3f6003e..f7c75f900487 100644 --- a/cmux-tui/crates/acpmux/src/adopt_tools.rs +++ b/cmux-tui/crates/acpmux/src/adopt_tools.rs @@ -42,6 +42,16 @@ impl PastTool { } } } + // An edit that failed changed nothing: its files keep no +/- counts. + if failed + && let Some(changes) = + self.call.pointer_mut("/rawInput/changes").and_then(Value::as_array_mut) + { + for change in changes.iter_mut().filter_map(Value::as_object_mut) { + change.remove("additions"); + change.remove("deletions"); + } + } self.ended_at = at.or(self.ended_at); } } @@ -270,12 +280,22 @@ fn joined(lines: &[String]) -> String { /// A Codex file change (`FileChange`, or an older patch event): one edit over its files, each /// hunk an ACP `diff` of its lines, the call's input naming each file with its +/- counts (the -/// edited-files card's numbers). A hunk too big to draw is shown as the diff's text. -fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> Option { +/// edited-files card's numbers). A hunk too big to draw is shown as the diff's text. A hunk is +/// part of its file, not the whole of it, so its diff says so (`_meta.acpmux.fragment`): the +/// card's Undo needs whole-file texts. A change that failed (declined, or a patch that did not +/// apply) changed nothing: it keeps its files and its error, but no diffs or counts. +fn codex_file_change( + id: &str, + value: &Value, + status: &str, + error: Option<&str>, + at: Option, +) -> Option { let files = changes(value); if files.is_empty() { return None; } + let failed = status == "failed"; let mut content = Vec::new(); let mut locations = Vec::new(); let mut summary = Vec::new(); @@ -300,7 +320,7 @@ fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> content.push(text_content(&format!("{target}\n{}", change.text))); } for hunk in &parts { - content.push(json!({"type": "diff", "path": target, "oldText": joined(&hunk.old), "newText": joined(&hunk.new)})); + content.push(json!({"type": "diff", "path": target, "oldText": joined(&hunk.old), "newText": joined(&hunk.new), "_meta": {"acpmux": {"fragment": true}}})); } // A location's line numbers its file's diffs: only one hunk starts there. locations.push(match parts.as_slice() { @@ -310,7 +330,11 @@ fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> parts.iter().fold((0, 0), |(a, d), h| (a + h.additions, d + h.deletions)) } }; - let mut entry = json!({"path": target, "type": change.kind, "additions": additions, "deletions": deletions}); + let mut entry = json!({"path": target, "type": change.kind}); + if !failed { + entry["additions"] = json!(additions); + entry["deletions"] = json!(deletions); + } if change.move_path.is_some() { entry["from"] = json!(change.path); } @@ -318,7 +342,9 @@ fn codex_file_change(id: &str, value: &Value, status: &str, at: Option) -> } // An edit too big to draw shows its files' diffs as text. let mut content = Value::Array(content); - if oversize(&content) { + if failed { + content = error.map(text_content).into_iter().collect(); + } else if oversize(&content) { content = files .iter() .map(|c| { @@ -390,7 +416,10 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result codex_command(id, item, at)?, - "FileChange" => codex_file_change(id, item.get("changes")?, status(item), at)?, + "FileChange" => { + let error = s(item, "stderr").filter(|t| !t.is_empty()); + codex_file_change(id, item.get("changes")?, status(item), error, at)? + } "McpToolCall" => codex_mcp(id, item, at), "Extension" if s(item, "kind") == Some("web.search") => { let query = s(item, "query").unwrap_or(""); @@ -411,7 +440,10 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result { let id = s(payload, "call_id")?; - Some((Ok(codex_file_change(id, payload.get("changes")?, "completed", at)?), false)) + Some(( + Ok(codex_file_change(id, payload.get("changes")?, "completed", None, at)?), + false, + )) } "patch_apply_end" => { let id = s(payload, "call_id")?; @@ -420,16 +452,16 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result(); let unnamed = 0; for (const { tool } of tools) { if (toolRunCategory(tool!.kind) !== "edited") continue; - const named = [...(tool!.diffs ?? []), ...(tool!.locations ?? [])].map((file) => file.path); + const named = tool!.diffs?.length + ? [...tool!.diffs, ...(tool!.locations ?? [])].map((file) => file.path) + : editedPaths(tool!); if (named.length === 0) unnamed += 1; for (const path of named) paths.add(path); } diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 9a0490a171f6..b7a2afece628 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -335,6 +335,7 @@ export function toolDiffs(content: any, locations: any): AcpmuxFileDiff[] | unde oldText: typeof item.oldText === "string" ? item.oldText : undefined, newText: item.newText, line: lines.get(item.path), + ...(item.fragment === true || item._meta?.acpmux?.fragment === true ? { fragment: true as const } : {}), })); return diffs.length ? diffs : undefined; } diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts index ad9863af8360..9653116af483 100644 --- a/webviews/src/agent-session/acpmux/model.ts +++ b/webviews/src/agent-session/acpmux/model.ts @@ -152,8 +152,9 @@ export type AcpmuxActivity = { }; /// A file change from an ACP tool call's `diff` content: `oldText` is absent for a new file. -/// `line` is where the change starts, when the tool call's locations name it. -export type AcpmuxFileDiff = { path: string; oldText?: string; newText: string; line?: number }; +/// `line` is where the change starts, when the tool call's locations name it. `fragment`: the +/// texts are part of the file, not all of it (`_meta.acpmux.fragment`, a reopened Codex hunk). +export type AcpmuxFileDiff = { path: string; oldText?: string; newText: string; line?: number; fragment?: true }; /// An agent's ask for a secret (cx-ncc.42, acpmux `hub/secrets.rs`): the `secret_request` record /// holds only the name and reason, never a value. The card answers with `_acpmux/secret_submit`. diff --git a/webviews/src/agent-session/acpmux/turnChanges/model.ts b/webviews/src/agent-session/acpmux/turnChanges/model.ts index 6e2ec7eb2079..8520fafc88f5 100644 --- a/webviews/src/agent-session/acpmux/turnChanges/model.ts +++ b/webviews/src/agent-session/acpmux/turnChanges/model.ts @@ -42,6 +42,8 @@ export function fileUndo(diffs: readonly AcpmuxFileDiff[]): FileUndo { const last = diffs[diffs.length - 1]; // The host takes absolute paths only; a path as the agent wrote it (`~/x`, `src/x`) is not one. if (!first || !last || !first.path.startsWith("/")) return { kind: "cannot" }; + // A diff marked as part of its file (a reopened Codex hunk) has no whole-file text to restore. + if (diffs.some((diff) => diff.fragment)) return { kind: "cannot" }; for (let index = 1; index < diffs.length; index++) if (diffs[index]!.oldText !== diffs[index - 1]!.newText) return { kind: "cannot" }; // An empty result could be a replace-all of a fragment: the original is not known. diff --git a/webviews/test/agent-pane-adopted-tool-calls.test.ts b/webviews/test/agent-pane-adopted-tool-calls.test.ts index 8e9aeac78a7e..67b93d4ec022 100644 --- a/webviews/test/agent-pane-adopted-tool-calls.test.ts +++ b/webviews/test/agent-pane-adopted-tool-calls.test.ts @@ -38,28 +38,46 @@ await requireBrowserLane("agent-pane-adopted-tool-calls.test.ts", async () => { kind: string; msg: unknown; }[]; - const events = replay.map((record) => ({ sessionId: CHAT, ...record })); - const lastSeq = events.at(-1)!.seq; - const session = { - sessionId: CHAT, - cwd: "/home/dev/demo", - title: "Bump the version", - harness: "codex", - family: "codex", - status: "idle", - turnCount: 1, - updatedAt: events.at(-1)!.at, - }; - function answer(method: string): unknown { + // The same turn, its edit recorded as a patch whose files only its input names: no diffs and + // no locations, as an edit too big to draw or a harness that sends no diff content. + const PATCH_CHAT = "0190a000-0000-7000-8000-0000000000f8"; + const patchOnly = replay.map((record) => { + const update = (record.msg as { params?: { update?: Record } }).params?.update; + if (!update || (update.toolCallId !== "exec-4" && update.kind !== "edit")) return record; + const { locations: _locations, content: _content, ...rest } = update; + const params = (record.msg as { params: Record }).params; + return { ...record, msg: { ...(record.msg as object), params: { ...params, update: rest } } }; + }); + const chats = new Map( + [ + [CHAT, replay], + [PATCH_CHAT, patchOnly], + ].map(([id, records]) => { + const events = (records as typeof replay).map((record) => ({ sessionId: id as string, ...record })); + const session = { + sessionId: id as string, + cwd: "/home/dev/demo", + title: "Bump the version", + harness: "codex", + family: "codex", + status: "idle", + turnCount: 1, + updatedAt: events.at(-1)!.at, + }; + return [id as string, { session, events, lastSeq: events.at(-1)!.seq }]; + }), + ); + function answer(method: string, params: { sessionId?: string } = {}): unknown { + const chat = chats.get(params.sessionId ?? CHAT) ?? chats.get(CHAT)!; switch (method) { case "initialize": return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; case "_acpmux/watch": - return { sessions: [session] }; + return { sessions: [...chats.values()].map((c) => c.session) }; case "_acpmux/attach": - return { session, lastSeq, events }; + return { session: chat.session, lastSeq: chat.lastSeq, events: chat.events }; case "_acpmux/events": - return { events: [], hasMore: false, lastSeq }; + return { events: [], hasMore: false, lastSeq: chat.lastSeq }; default: return null; } @@ -68,14 +86,14 @@ await requireBrowserLane("agent-pane-adopted-tool-calls.test.ts", async () => { let daemon: ReturnType | undefined; let vite: Awaited> | undefined; let browser: Browser | undefined; - const url = () => { + const url = (chat = CHAT) => { const { port } = vite!.httpServer!.address() as { port: number }; const endpoint = `ws://127.0.0.1:${daemon!.port}/`; - return `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${CHAT}`; + return `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${chat}`; }; - const open = async (): Promise => { + const open = async (chat = CHAT): Promise => { const page = await browser!.newPage({ viewport: { width: 900, height: 900 } }); - await page.goto(url()); + await page.goto(url(chat)); await page.getByText(ANSWER).waitFor(); return page; }; @@ -88,9 +106,9 @@ await requireBrowserLane("agent-pane-adopted-tool-calls.test.ts", async () => { }, websocket: { message(ws, raw) { - const message = JSON.parse(String(raw)) as { id?: number; method: string }; + const message = JSON.parse(String(raw)) as { id?: number; method: string; params?: { sessionId?: string } }; if (message.id !== undefined) - ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method, message.params) })); }, }, }); @@ -144,4 +162,11 @@ await requireBrowserLane("agent-pane-adopted-tool-calls.test.ts", async () => { await page.getByText(ANSWER).waitFor(); await page.close(); }); + + test("a patch whose files only its input names counts each file, not one", async () => { + const page = await open(PATCH_CHAT); + await page.locator("button.cv-worked").click(); + await page.getByRole("button", { name: "Edited files, ran commands" }).waitFor(); + await page.close(); + }); }); diff --git a/webviews/test/fixtures/adopted-codex-turn.events.json b/webviews/test/fixtures/adopted-codex-turn.events.json index 4c09a978ee68..e1723e9ae0e8 100644 --- a/webviews/test/fixtures/adopted-codex-turn.events.json +++ b/webviews/test/fixtures/adopted-codex-turn.events.json @@ -238,12 +238,22 @@ "update": { "content": [ { + "_meta": { + "acpmux": { + "fragment": true + } + }, "newText": "# Changelog\n\n## 1.3.0\n- Bump the version to 1.3.0\n\n## 1.2.0\n", "oldText": "# Changelog\n\n## 1.2.0\n", "path": "/home/dev/demo/CHANGELOG.md", "type": "diff" }, { + "_meta": { + "acpmux": { + "fragment": true + } + }, "newText": "[package]\nname = \"demo\"\nversion = \"1.3.0\"\nedition = \"2021\"\n", "oldText": "[package]\nname = \"demo\"\nversion = \"1.2.0\"\nedition = \"2021\"\n", "path": "/home/dev/demo/Cargo.toml", From cae2336c9ca0c86180a4429d5a3304845e159008 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 14:18:39 -0700 Subject: [PATCH 06/12] cmux-next: test the edited-files card, Changes menu entry and summary Outputs (failing) A reopened Codex turn that edited two files (hq#2569 item 4): the card lists each file with its counts and View changes opens the hunks (both already drawn now that #22155 replays the edits), the chat menu lists Changes, and the summary's Outputs lists the edited files. Co-Authored-By: Claude Opus 5.5 --- .../agent-pane-adopted-edited-files.test.ts | 156 ++++++++++++++++++ webviews/test/chat-header-fold.test.tsx | 12 +- webviews/test/sources-files-once.test.tsx | 22 +-- 3 files changed, 176 insertions(+), 14 deletions(-) create mode 100644 webviews/test/agent-pane-adopted-edited-files.test.ts diff --git a/webviews/test/agent-pane-adopted-edited-files.test.ts b/webviews/test/agent-pane-adopted-edited-files.test.ts new file mode 100644 index 000000000000..a2d2d9a1b6ed --- /dev/null +++ b/webviews/test/agent-pane-adopted-edited-files.test.ts @@ -0,0 +1,156 @@ +// l10n-allow-file: test fixture prompts and replies, not shipped UI. +// A reopened Codex turn that edited two files ends with the edited-files card (hq#2569 item 4): +// "Edited 2 files +4 -1" with a row and counts per file; View changes opens the turn's diff with +// its hunks; the chat menu lists Changes; the summary's Outputs lists the files. +// +// Before: the turn had no edit records, so none of these drew (#22155 replays them); with them, +// the chat menu still had no Changes (only the header button) and Outputs said "None" (its files +// were listed only under the summary's Changes). +// +// The records are acpmux's own replay of a Codex rollout (`adopt_replays_a_codex_turns_commands_and_file_edits` +// in cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs writes the fixture). The page is the +// pane's dev entry with its browser host, talking JSON-RPC to a stand-in acpmux serving them. +// Real Chromium from Playwright; CI fails the file when it cannot launch (requireBrowserLane). +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import path from "node:path"; +import { chromium, type Browser, type Page } from "playwright"; +import { requireBrowserLane } from "./support/requireBrowserLane"; +import { warmViteServer } from "./support/warmDevServer"; + +await requireBrowserLane("agent-pane-adopted-edited-files.test.ts", async () => { + setDefaultTimeout(120_000); + try { + await (await chromium.launch({ headless: true })).close(); + } catch { + console.warn("agent-pane-adopted-edited-files: skipping (run `bunx playwright install chromium`)"); + describe.skip("adopted edited files", () => test("chromium", () => {})); + return; + } + const { createServer } = await import("vite"); + const webviews = path.resolve(import.meta.dir, ".."); + + const CHAT = "0190a000-0000-7000-8000-0000000000f7"; + const ANSWER = "Bumped demo to 1.3.0"; + const replay = (await Bun.file(path.join(import.meta.dir, "fixtures/adopted-codex-turn.events.json")).json()) as { + seq: number; + at: number; + dir: string; + kind: string; + msg: unknown; + }[]; + const events = replay.map((record) => ({ sessionId: CHAT, ...record })); + const lastSeq = events.at(-1)!.seq; + const session = { + sessionId: CHAT, + cwd: "/home/dev/demo", + title: "Bump the version", + harness: "codex", + family: "codex", + status: "idle", + turnCount: 1, + updatedAt: events.at(-1)!.at, + }; + function answer(method: string): unknown { + switch (method) { + case "initialize": + return { protocolVersion: 1, _meta: { acpmux: { origin: "local", extensions: [], operations: [] } } }; + case "_acpmux/watch": + return { sessions: [session] }; + case "_acpmux/attach": + return { session, lastSeq, events }; + case "_acpmux/events": + return { events: [], hasMore: false, lastSeq }; + default: + return null; + } + } + + let daemon: ReturnType | undefined; + let vite: Awaited> | undefined; + let browser: Browser | undefined; + const url = () => { + const { port } = vite!.httpServer!.address() as { port: number }; + const endpoint = `ws://127.0.0.1:${daemon!.port}/`; + return `http://127.0.0.1:${port}/#endpoint=${encodeURIComponent(endpoint)}&token=t&session=${CHAT}`; + }; + const open = async (): Promise => { + const page = await browser!.newPage({ viewport: { width: 900, height: 900 } }); + await page.goto(url()); + await page.getByText(ANSWER).waitFor(); + return page; + }; + beforeAll(async () => { + daemon = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch(request, server) { + return server.upgrade(request, { data: undefined }) ? undefined : new Response("acpmux", { status: 426 }); + }, + websocket: { + message(ws, raw) { + const message = JSON.parse(String(raw)) as { id?: number; method: string }; + if (message.id !== undefined) + ws.send(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: answer(message.method) })); + }, + }, + }); + vite = await createServer({ + configFile: path.join(webviews, "vite.config.acpmux-pane.mjs"), + server: { port: 0, host: "127.0.0.1", strictPort: false }, + logLevel: "error", + }); + await vite.listen(); + await warmViteServer(vite, "/"); + browser = await chromium.launch({ headless: true }); + // A cold dev server's first load can stall in CI: load it here, again if it stalls. + for (let attempt = 1; ; attempt++) { + const warm = await browser.newPage(); + try { + await warm.goto(url()); + await warm.getByText(ANSWER).waitFor({ timeout: 25_000 }); + await warm.close(); + break; + } catch (error) { + await warm.close(); + if (attempt === 3) throw error; + } + } + }); + afterAll(async () => { + await browser?.close(); + await vite?.close(); + await daemon?.stop(true); + }); + + test("the edited-files card lists each file with its counts, and View changes opens the hunks", async () => { + const page = await open(); + const card = page.locator(".acpmux-edited"); + await expect(card.locator(".acpmux-edited-title").innerText()).resolves.toMatch(/^Edited 2 files\s*\+4\s*-1/); + const files = await card.locator(".acpmux-edited-file").allInnerTexts(); + expect(files.map((row) => row.replace(/\s+/g, " ").trim())).toEqual(["CHANGELOG.md +3 -0", "Cargo.toml +1 -1"]); + await card.getByRole("button", { name: "View changes" }).click(); + // The browser host has no pane beside the chat: the turn's diff opens in place, its hunks drawn. + await page.getByText('version = "1.3.0"').first().waitFor(); + await page.getByText("- Bump the version to 1.3.0").first().waitFor(); + await page.close(); + }); + + test("the chat menu lists Changes", async () => { + const page = await open(); + await page.getByRole("button", { name: "Chat actions" }).click(); + const items = page.locator(".acpmux-chat-menu-item .acpmux-chat-menu-label"); + await items.first().waitFor(); + expect(await items.allTextContents()).toContain("Changes"); + await page.close(); + }); + + test("the summary's Outputs lists the edited files", async () => { + const page = await open(); + await page.getByRole("button", { name: "Chat summary" }).click(); + const outputs = page.locator('section[aria-label="Outputs"] li'); + await outputs.first().waitFor(); + const rows = (await outputs.allInnerTexts()).map((row) => row.replace(/\s+/g, " ").trim()); + expect(rows).toEqual(["CHANGELOG.md +3 -0", "Cargo.toml +1 -1"]); + await page.close(); + }); +}); diff --git a/webviews/test/chat-header-fold.test.tsx b/webviews/test/chat-header-fold.test.tsx index 03911982096c..c0037ec2163f 100644 --- a/webviews/test/chat-header-fold.test.tsx +++ b/webviews/test/chat-header-fold.test.tsx @@ -2,7 +2,8 @@ // everything in menus): Changes (while the chat has changes), Preview (while there is a page), Terminal // and Browser, then Sources and "...". [+] New tab, Files and Split Down fold into the top of "...", and // run with the toggle mode as their buttons did. A quick action the person shows in the header -// (layout.json) leaves "..." again. +// (layout.json) leaves "..." again, except Changes: while the chat has changes, "..." lists it too, as +// the Codex and Claude apps' chat menus do (hq#2569 item 4). import { afterAll, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; import { act } from "react"; @@ -89,17 +90,20 @@ test("the header shows Changes, Preview, Terminal and Browser; New tab, Files an render(); expect(shown()).toEqual(["changes", "preview", "terminal", "browser"]); await act(async () => void openPicker("Chat actions")); - expect(labels()).toEqual(["New tab", "Files", "Split Down", "Rename"]); + expect(labels()).toEqual(["Changes", "New tab", "Files", "Split Down", "Rename"]); const rows = () => [...doc.querySelectorAll(".acpmux-chat-menu-popover .acpmux-chat-menu-item")]; - await act(async () => rows()[2].click()); + await act(async () => rows()[3].click()); expect(runs).toEqual(["splitDown:toggle"]); + await act(async () => void openPicker("Chat actions")); + await act(async () => rows()[0].click()); + expect(runs).toEqual(["splitDown:toggle", "changes:toggle"]); // Shown in the header again: its button is back and "..." no longer lists it. act(() => configureQuickActions({ quickActions: ["changes", "preview", "terminal", "browser", "splitDown"] })); render(); expect(shown()).toEqual(["changes", "preview", "terminal", "browser", "splitDown"]); await act(async () => void openPicker("Chat actions")); - expect(labels()).toEqual(["New tab", "Files", "Rename"]); + expect(labels()).toEqual(["Changes", "New tab", "Files", "Rename"]); act(() => root?.unmount()); root = undefined; configureQuickActions({}); diff --git a/webviews/test/sources-files-once.test.tsx b/webviews/test/sources-files-once.test.tsx index 5c8ffa73f503..7ff07675043f 100644 --- a/webviews/test/sources-files-once.test.tsx +++ b/webviews/test/sources-files-once.test.tsx @@ -1,6 +1,7 @@ // The chat's Sources popover listed a file the last turn edited twice (cx-0h4xq, dogfood run 6): -// under Outputs, the chat's files, and again under Changes, the last turn's. Codex lists it once: -// a file the last turn changed shows under Changes, and Outputs keeps the chat's other files. +// under Outputs, the chat's files, and again under Changes, the last turn's. It lists it once, under +// Outputs with every file the chat changed (hq#2569 item 4: a chat that edited files said "Outputs: +// None"); Changes is the one row that opens the last turn's diff with its totals. import { afterAll, beforeAll, expect, test } from "bun:test"; import { JSDOM } from "jsdom"; import { act } from "react"; @@ -53,27 +54,28 @@ async function sourcesRows(outputs: never[], changed: never[]) { return sections; } -function expectOnceUnderChanges(sections: Record) { +function expectOnceUnderOutputs(sections: Record) { const rows = Object.values(sections).flat(); expect(rows.filter((row) => row.includes("upload.sh")).length).toBe(1); - expect((sections["Changes"] ?? []).some((row) => row.includes("upload.sh"))).toBe(true); + expect((sections["Outputs"] ?? []).some((row) => row.includes("upload.sh"))).toBe(true); expect((sections["Outputs"] ?? []).some((row) => row.includes("README.md"))).toBe(true); + expect(sections["Changes"]).toEqual(["Changes+1-0"]); } -test("a file the last turn changed shows once, under Changes", async () => { - expectOnceUnderChanges( +test("a file the last turn changed shows once, under Outputs", async () => { + expectOnceUnderOutputs( await sourcesRows([file("/repo/src/upload.sh", 1), file("/repo/README.md", 4)], [file("/repo/src/upload.sh", 1)]), ); }); -// Once the turn's checkpoint loads, Changes lists its files under the repository root, while the -// chat's outputs keep the paths the agent wrote (`~/code/x/src/upload.sh`). -test("a file the turn's checkpoint lists shows once, under Changes", async () => { +// Once the turn's checkpoint loads, its files are under the repository root, while the chat's +// outputs keep the paths the agent wrote (`~/code/x/src/upload.sh`): still listed once. +test("a file the turn's checkpoint lists shows once, under Outputs", async () => { const checkpoint = { ...(file("/Users/me/code/x/src/upload.sh", 1) as object), displayPath: "src/upload.sh", } as never; - expectOnceUnderChanges( + expectOnceUnderOutputs( await sourcesRows([file("~/code/x/src/upload.sh", 1), file("~/code/x/README.md", 4)], [checkpoint]), ); }); From 6e7f72bf0165d8974c220de23619e290c39b0161 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 14:35:43 -0700 Subject: [PATCH 07/12] cmux-next: list Changes in the chat menu and the edited files under Outputs The chat actions menu now lists Changes while the chat has changes, beside its header button, as the Codex and Claude apps' chat menus do. The summary's Outputs lists every file the chat changed: it used to drop the last turn's files (listing them only under Changes), so a chat whose one editing turn was the last said "Outputs: None". Changes is now the one row that opens the turn's diff with its totals (hq#2569 item 4). Co-Authored-By: Claude Opus 5.5 --- .../acpmux/header/ChatHeaderTools.tsx | 4 ++- .../acpmux/summary/SummaryBody.tsx | 34 ++++--------------- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx index 9c215d783b44..28b73ba9079d 100644 --- a/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx +++ b/webviews/src/agent-session/acpmux/header/ChatHeaderTools.tsx @@ -167,6 +167,7 @@ export function ChatHeaderTools({ const target = useRef(undefined); const [menuTarget, setMenuTarget] = useState(); // The quick actions without a button, at the top of "...": each while it would show as a button. + // Changes is listed there with its button too. // Built when "..." reads its rows, after the App's tab state refresh (onMenuOpen): the props of this // render, and the lit splits as the App last pushed them. const live = useRef({ @@ -210,7 +211,8 @@ export function ChatHeaderTools({ const { lit } = headerState(); const shownIds = new Set(actions.map((action) => action.id)); const folded: ChatMenuItem[] = []; - if (onChanges && !shownIds.has("changes") && (hasChanges || lit.includes(HEADER_ACTIONS.changes))) + // Changes stays in "..." beside its button, as in the Codex and Claude apps' chat menus (hq#2569 item 4). + if (onChanges && (hasChanges || lit.includes(HEADER_ACTIONS.changes))) folded.push({ key: "quick-changes", label: t("header.changes"), diff --git a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx index 0138fa4feee2..f488c843191b 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx @@ -1,6 +1,5 @@ import React, { type ReactNode } from "react"; import { Counts } from "../changes/Counts"; -import { changedByTools } from "../changes/turnCheckpoint"; import { useT, type StringKey } from "../i18n"; import { Icon } from "../icons/Icon"; import { rowIconSize } from "../icons/iconSize"; @@ -15,8 +14,9 @@ const ROW_ICON = rowIconSize(12); /// always show, in the Codex app's order, with "None" while empty; pull requests, background /// tasks and wakeups follow when there are any. /// Pull requests and sources are links, so they open, copy and open in a new tab as links do; -/// an output opens the changes view at that file. A file the last turn changed is listed under -/// Changes only. Following a link calls `onFollow`. A chat +/// an output opens the changes view at that file. Outputs lists every file the chat changed, once +/// (hq#2569 item 4, cx-0h4xq); Changes is one row that opens the last turn's diff with its totals. +/// Following a link calls `onFollow`. A chat /// with images or renders offers its gallery under Outputs (GalleryDialog.tsx). A chat whose /// subagents acpmux reports lists them with their live state (SubagentStatusList.tsx) in the /// Subagents place. The summary card (SummaryCard.tsx) draws it. @@ -57,19 +57,12 @@ export function SummaryBody({ subagents?: ReactNode; }) { const t = useT(); - // A file the last turn changed shows once, under Changes (as in Codex); Outputs keeps the rest. - // A loaded checkpoint lists them under the repository root, outputs as the agent wrote them. - const changed = changes?.files ?? []; - const outputs = - changed.length > 0 - ? summary.outputs.filter((output) => !changed.some((file) => changedByTools(file, [output]))) - : summary.outputs; return ( <> (
  • } - row={(file) => ( -
  • - -
  • - )} + row={() => null} /> )} Date: Mon, 19 Oct 2026 19:32:19 -0700 Subject: [PATCH 08/12] cmux-next: Outputs keeps the files only the turn's checkpoint knows, and a reopened Codex hunk offers no Undo A file a shell command or formatter edited is in the last turn's checkpoint but in no tool diff: it is listed under Outputs too and opens that turn's diff at the file, as its Changes row did. Every Outputs file row is a summary file row, so the fourteen-file stage reads its files there. The adopted edited-files card offers no Undo for replayed Codex hunks. Co-Authored-By: Claude Opus 5.5 --- .../acpmux/summary/SummaryBody.tsx | 42 ++++++++++++------- .../acpmux/turnChanges/editedFiles.gallery.ts | 2 +- .../agent-pane-adopted-edited-files.test.ts | 4 ++ webviews/test/sources-changes.test.ts | 4 +- webviews/test/sources-files-once.test.tsx | 25 ++++++++++- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx index f488c843191b..800c047bc738 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx @@ -1,5 +1,6 @@ import React, { type ReactNode } from "react"; import { Counts } from "../changes/Counts"; +import { changedByTools } from "../changes/turnCheckpoint"; import { useT, type StringKey } from "../i18n"; import { Icon } from "../icons/Icon"; import { rowIconSize } from "../icons/iconSize"; @@ -15,7 +16,9 @@ const ROW_ICON = rowIconSize(12); /// tasks and wakeups follow when there are any. /// Pull requests and sources are links, so they open, copy and open in a new tab as links do; /// an output opens the changes view at that file. Outputs lists every file the chat changed, once -/// (hq#2569 item 4, cx-0h4xq); Changes is one row that opens the last turn's diff with its totals. +/// (hq#2569 item 4, cx-0h4xq), with the files only the last turn's checkpoint knows (a shell +/// command or formatter edited them) opening that turn's diff at the file; Changes is one row +/// that opens the last turn's diff with its totals. /// Following a link calls `onFollow`. A chat /// with images or renders offers its gallery under Outputs (GalleryDialog.tsx). A chat whose /// subagents acpmux reports lists them with their live state (SubagentStatusList.tsx) in the @@ -57,26 +60,33 @@ export function SummaryBody({ subagents?: ReactNode; }) { const t = useT(); + // A loaded checkpoint lists its files under the repository root, outputs as the agent wrote them. + const checkpointOnly = (changes?.files ?? []).filter((file) => !changedByTools(file, summary.outputs)); + const outputs = [...summary.outputs, ...checkpointOnly]; return ( <> ( -
  • - -
  • - )} + items={outputs} + row={(file) => { + const inTurnDiff = checkpointOnly.includes(file); + const open = inTurnDiff ? onOpenChanges : onOpenOutput; + return ( +
  • + +
  • + ); + }} footer={ galleryCount > 0 && onOpenGallery && ( diff --git a/webviews/src/agent-session/acpmux/turnChanges/editedFiles.gallery.ts b/webviews/src/agent-session/acpmux/turnChanges/editedFiles.gallery.ts index c19cf8aece0b..02be7bd94825 100644 --- a/webviews/src/agent-session/acpmux/turnChanges/editedFiles.gallery.ts +++ b/webviews/src/agent-session/acpmux/turnChanges/editedFiles.gallery.ts @@ -185,7 +185,7 @@ const variants: Record = { ]), }, "sources-changes": { - note: "Play: open Sources; Changes lists the turn's fourteen files under its totals, five until View all. A file opens the turn's diff at it.", + note: "Play: open Sources; Outputs lists the turn's fourteen files, five until View all, and Changes their totals. A file opens the turn's diff at it.", height: 700, snapshot: turn(FOURTEEN, "Move the retry policy into its own module"), play: async ({ click, waitFor, document }) => { diff --git a/webviews/test/agent-pane-adopted-edited-files.test.ts b/webviews/test/agent-pane-adopted-edited-files.test.ts index a2d2d9a1b6ed..523375869a85 100644 --- a/webviews/test/agent-pane-adopted-edited-files.test.ts +++ b/webviews/test/agent-pane-adopted-edited-files.test.ts @@ -128,6 +128,10 @@ await requireBrowserLane("agent-pane-adopted-edited-files.test.ts", async () => await expect(card.locator(".acpmux-edited-title").innerText()).resolves.toMatch(/^Edited 2 files\s*\+4\s*-1/); const files = await card.locator(".acpmux-edited-file").allInnerTexts(); expect(files.map((row) => row.replace(/\s+/g, " ").trim())).toEqual(["CHANGELOG.md +3 -0", "Cargo.toml +1 -1"]); + // A reopened Codex edit is replayed as hunks, parts of each file (`_meta.acpmux.fragment`): no + // whole-file text to put back, so the card offers no Undo the host would refuse. + await card.hover(); + expect(await card.locator(".acpmux-edited-undo").count()).toBe(0); await card.getByRole("button", { name: "View changes" }).click(); // The browser host has no pane beside the chat: the turn's diff opens in place, its hunks drawn. await page.getByText('version = "1.3.0"').first().waitFor(); diff --git a/webviews/test/sources-changes.test.ts b/webviews/test/sources-changes.test.ts index b54d3a3859df..f1ace418b4db 100644 --- a/webviews/test/sources-changes.test.ts +++ b/webviews/test/sources-changes.test.ts @@ -1,7 +1,7 @@ // The chat's summary card holds the last turn's diffs (cx-ncc.43). In Chromium on the gallery // stage, a turn that edited fourteen files: -// - the card's Changes section lists the turn's files under its totals row, each with its own -// counts, five at first and every one after View all; +// - the card's Outputs section lists the turn's files, each with its own counts, five at first +// and every one after View all (Changes is the one totals row, hq#2569 item 4); // - a file there opens the turn's diff at that file, and the card hides behind the diff review. import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; import path from "node:path"; diff --git a/webviews/test/sources-files-once.test.tsx b/webviews/test/sources-files-once.test.tsx index 7ff07675043f..af6d9a86625a 100644 --- a/webviews/test/sources-files-once.test.tsx +++ b/webviews/test/sources-files-once.test.tsx @@ -37,14 +37,16 @@ afterAll(() => { const file = (path: string, additions: number) => ({ path, displayPath: path.split("/").pop()!, edits: [], additions, deletions: 0, created: false }) as never; -async function sourcesRows(outputs: never[], changed: never[]) { +async function sourcesRows(outputs: never[], changed: never[], onOpenChanges: (path?: string) => void = () => {}) { const { createRoot } = await import("react-dom/client"); const { SummaryBody } = await import("../src/agent-session/acpmux/summary/SummaryBody"); act(() => root?.unmount()); root = createRoot(host); const summary = { scheduled: [], pullRequests: [], outputs, subagents: [], sources: [], backgroundTasks: [] }; const changes = { additions: 1, deletions: 0, files: changed }; - await act(async () => root!.render( {}} />)); + await act(async () => + root!.render(), + ); const sections: Record = Object.fromEntries( [...host.querySelectorAll("section")].map((section) => [ section.getAttribute("aria-label") ?? section.querySelector("h3")?.textContent, @@ -79,3 +81,22 @@ test("a file the turn's checkpoint lists shows once, under Outputs", async () => await sourcesRows([file("~/code/x/src/upload.sh", 1), file("~/code/x/README.md", 4)], [checkpoint]), ); }); + +// A turn can edit one file with an edit tool and another with a shell command or formatter: only +// the checkpoint knows the second. It is listed under Outputs too, and opens the turn's diff there. +test("a file only the turn's checkpoint knows shows under Outputs and opens the turn's diff at it", async () => { + const opened: (string | undefined)[] = []; + const formatted = { ...(file("/repo/src/format.ts", 2) as object), displayPath: "src/format.ts" } as never; + const sections = await sourcesRows( + [file("/repo/src/upload.sh", 1)], + [file("/repo/src/upload.sh", 1), formatted], + (path) => opened.push(path), + ); + expect(sections["Outputs"]?.filter((row) => row.includes("format.ts")).length).toBe(1); + expect(sections["Outputs"]?.filter((row) => row.includes("upload.sh")).length).toBe(1); + const row = [...host.querySelectorAll("section button")].find((button) => + button.textContent?.includes("format.ts"), + )!; + await act(async () => row.click()); + expect(opened).toEqual(["/repo/src/format.ts"]); +}); From 5451569e1ed688c8913ed86ee73de7efe2a69652 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 19 Oct 2026 20:47:18 -0700 Subject: [PATCH 09/12] cmux-next: an unconfirmed Claude edit shows no diff; a file the last turn changed again opens that turn's diff A Claude Code edit cut off before its result, or refused, changed nothing: its proposed diffs wait in PastTool.proposed and settle only with a result that did not fail, so the edited-files card and Outputs never count them. An output the last turn changed again (a shell command after an earlier edit tool) now opens the last turn's diff with its counts, listed once. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/adopt_tools.rs | 61 ++++++++++++++++--- .../acpmux/summary/SummaryBody.tsx | 21 +++++-- webviews/test/sources-files-once.test.tsx | 32 +++++++++- 3 files changed, 99 insertions(+), 15 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/adopt_tools.rs b/cmux-tui/crates/acpmux/src/adopt_tools.rs index f7c75f900487..5843d8dd5254 100644 --- a/cmux-tui/crates/acpmux/src/adopt_tools.rs +++ b/cmux-tui/crates/acpmux/src/adopt_tools.rs @@ -20,6 +20,9 @@ pub struct PastTool { /// When it started and ended (ms since the epoch), when recorded. pub started_at: Option, pub ended_at: Option, + /// An edit's proposed diffs, kept out of `done` until a result that did not fail confirms + /// them: a Claude Code edit cut off before its result, or refused, changed nothing. + pub proposed: Option, } /// A tool call's result, found after the call (Claude Code's `tool_result`, Codex's older @@ -35,6 +38,9 @@ impl PastTool { /// Settles the call with `result` at `at`. pub fn settle(&mut self, result: &ToolResult, at: Option) { let failed = result.done["status"] == "failed"; + if let (Some(proposed), false) = (self.proposed.take(), failed) { + self.done["content"] = proposed; + } if let (Some(done), Some(update)) = (self.done.as_object_mut(), result.done.as_object()) { for (key, value) in update { if key != "content" || failed || !done.contains_key("content") { @@ -164,6 +170,7 @@ fn codex_command(id: &str, item: &Value, at: Option) -> Option { done, started_at: at.map(|end| end.saturating_sub(took.unwrap_or(0))), ended_at: at, + proposed: None, }) } @@ -369,6 +376,7 @@ fn codex_file_change( done: json!({"status": status, "content": content}), started_at: at, ended_at: at, + proposed: None, }) } @@ -400,6 +408,7 @@ fn codex_mcp(id: &str, item: &Value, at: Option) -> PastTool { }), started_at: at.map(|end| end.saturating_sub(took.unwrap_or(0))), ended_at: at, + proposed: None, } } @@ -429,6 +438,7 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result return None, @@ -488,8 +498,8 @@ pub fn codex_tool(record: &Value, at: Option) -> Option<(Result) -> Vec { content .as_array() @@ -502,11 +512,14 @@ pub fn claude_tool_uses(content: &Value, at: Option) -> Vec { let input = block.get("input").cloned().unwrap_or(Value::Null); let mut call = crate::claude_stdio::tool_call_fields(name, &input); let diffs = call.as_object_mut().and_then(|c| c.remove("content")); - let mut done = json!({"status": "failed"}); - if let Some(diffs) = diffs.filter(|d| !oversize(d)) { - done["content"] = diffs; - } - Some(PastTool { id: id.to_owned(), call, done, started_at: at, ended_at: None }) + Some(PastTool { + id: id.to_owned(), + call, + done: json!({"status": "failed"}), + started_at: at, + ended_at: None, + proposed: diffs.filter(|d| !oversize(d)), + }) }) .collect() } @@ -537,3 +550,37 @@ pub fn claude_tool_results(content: &Value) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + fn edit(id: &str) -> Value { + json!([{"type": "tool_use", "id": id, "name": "Edit", + "input": {"file_path": "/p/src/lib.rs", "old_string": "a", "new_string": "b"}}]) + } + + fn result(id: &str, is_error: bool) -> ToolResult { + let block = json!([{"type": "tool_result", "tool_use_id": id, "content": "ok", "is_error": is_error}]); + claude_tool_results(&block).remove(0) + } + + /// A Claude Code edit shows its diff only once a result that did not fail confirms it: one + /// cut off before its result, or refused, changed nothing and counts no edited file. + #[test] + fn a_claude_edit_shows_its_diff_only_when_its_result_confirms_it() { + let [cut_off] = claude_tool_uses(&edit("t1"), Some(1)).try_into().unwrap(); + assert_eq!(cut_off.done, json!({"status": "failed"})); + + let [mut refused] = claude_tool_uses(&edit("t2"), Some(1)).try_into().unwrap(); + refused.settle(&result("t2", true), Some(2)); + assert_eq!(refused.done["status"], "failed"); + assert_eq!(refused.done["content"], json!([text_content("ok")])); + + let [mut applied] = claude_tool_uses(&edit("t3"), Some(1)).try_into().unwrap(); + applied.settle(&result("t3", false), Some(2)); + assert_eq!(applied.done["status"], "completed"); + assert_eq!(applied.done["content"][0]["type"], "diff", "{}", applied.done); + assert_eq!(applied.done["content"][0]["newText"], "b"); + } +} diff --git a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx index 800c047bc738..c82876d0f3de 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx @@ -16,8 +16,8 @@ const ROW_ICON = rowIconSize(12); /// tasks and wakeups follow when there are any. /// Pull requests and sources are links, so they open, copy and open in a new tab as links do; /// an output opens the changes view at that file. Outputs lists every file the chat changed, once -/// (hq#2569 item 4, cx-0h4xq), with the files only the last turn's checkpoint knows (a shell -/// command or formatter edited them) opening that turn's diff at the file; Changes is one row +/// (hq#2569 item 4, cx-0h4xq). A file the last turn changed, by a tool or as only its checkpoint +/// knows (a shell command or formatter edited it), opens that turn's diff at the file; Changes is one row /// that opens the last turn's diff with its totals. /// Following a link calls `onFollow`. A chat /// with images or renders offers its gallery under Outputs (GalleryDialog.tsx). A chat whose @@ -61,16 +61,25 @@ export function SummaryBody({ }) { const t = useT(); // A loaded checkpoint lists its files under the repository root, outputs as the agent wrote them. - const checkpointOnly = (changes?.files ?? []).filter((file) => !changedByTools(file, summary.outputs)); - const outputs = [...summary.outputs, ...checkpointOnly]; + // A file the last turn changed opens that turn's diff, also when an earlier turn's edit tool made + // it an output: its last change is the one to show. + const lastTurn = changes?.files ?? []; + const rows = [ + ...summary.outputs.map((output) => { + const turn = lastTurn.find((file) => changedByTools(file, [output])); + return turn ? { file: turn, inTurnDiff: true } : { file: output, inTurnDiff: false }; + }), + ...lastTurn.filter((file) => !changedByTools(file, summary.outputs)).map((file) => ({ file, inTurnDiff: true })), + ]; + // Two outputs the agent wrote differently (`~/x/a.ts`, `/Users/me/x/a.ts`) are one checkpoint file. + const outputs = rows.filter((row, index) => rows.findIndex((other) => other.file.path === row.file.path) === index); return ( <> { - const inTurnDiff = checkpointOnly.includes(file); + row={({ file, inTurnDiff }) => { const open = inTurnDiff ? onOpenChanges : onOpenOutput; return (
  • diff --git a/webviews/test/sources-files-once.test.tsx b/webviews/test/sources-files-once.test.tsx index af6d9a86625a..03f7069e0a1c 100644 --- a/webviews/test/sources-files-once.test.tsx +++ b/webviews/test/sources-files-once.test.tsx @@ -37,7 +37,12 @@ afterAll(() => { const file = (path: string, additions: number) => ({ path, displayPath: path.split("/").pop()!, edits: [], additions, deletions: 0, created: false }) as never; -async function sourcesRows(outputs: never[], changed: never[], onOpenChanges: (path?: string) => void = () => {}) { +async function sourcesRows( + outputs: never[], + changed: never[], + onOpenChanges: (path?: string) => void = () => {}, + onOpenOutput: (path: string) => void = () => {}, +) { const { createRoot } = await import("react-dom/client"); const { SummaryBody } = await import("../src/agent-session/acpmux/summary/SummaryBody"); act(() => root?.unmount()); @@ -45,7 +50,9 @@ async function sourcesRows(outputs: never[], changed: never[], onOpenChanges: (p const summary = { scheduled: [], pullRequests: [], outputs, subagents: [], sources: [], backgroundTasks: [] }; const changes = { additions: 1, deletions: 0, files: changed }; await act(async () => - root!.render(), + root!.render( + , + ), ); const sections: Record = Object.fromEntries( [...host.querySelectorAll("section")].map((section) => [ @@ -100,3 +107,24 @@ test("a file only the turn's checkpoint knows shows under Outputs and opens the await act(async () => row.click()); expect(opened).toEqual(["/repo/src/format.ts"]); }); + +// An earlier turn edited a file with a tool, and the last turn changed it again (by a shell command, +// say): its row opens the last turn's diff, where its latest change is, not the earlier turn's. +test("a file the last turn changed again opens the last turn's diff", async () => { + const opened: string[] = []; + const outputsOpened: string[] = []; + const again = { ...(file("/repo/src/upload.sh", 3) as object), displayPath: "src/upload.sh" } as never; + const sections = await sourcesRows( + [file("/repo/src/upload.sh", 1), file("/repo/README.md", 4)], + [again], + (path) => opened.push(path!), + (path) => outputsOpened.push(path), + ); + expect(sections["Outputs"]?.filter((row) => row.includes("upload.sh"))).toEqual(["src/upload.sh+3-0"]); + const button = (name: string) => + [...host.querySelectorAll("section button")].find((b) => b.textContent?.includes(name))!; + await act(async () => button("upload.sh").click()); + await act(async () => button("README.md").click()); + expect(opened).toEqual(["/repo/src/upload.sh"]); + expect(outputsOpened).toEqual(["/repo/README.md"]); +}); From 924fc9fe6a9cc98f29f2059de3a2af917f0d5a4e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 04:14:49 -0700 Subject: [PATCH 10/12] acpmux: adopt replay tests need no backdated transcript after #22139; saturate a Codex duration feat-cmux-next dropped the backdate helper when finished CLI chats began reopening directly (#22139), so the merged tests no longer compiled. A malformed duration no longer overflows. Co-Authored-By: Claude Opus 5.5 --- cmux-tui/crates/acpmux/src/adopt_tools.rs | 2 +- cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/cmux-tui/crates/acpmux/src/adopt_tools.rs b/cmux-tui/crates/acpmux/src/adopt_tools.rs index f7c75f900487..7228407e0744 100644 --- a/cmux-tui/crates/acpmux/src/adopt_tools.rs +++ b/cmux-tui/crates/acpmux/src/adopt_tools.rs @@ -90,7 +90,7 @@ fn duration_ms(value: Option<&Value>) -> Option { let value = value?; let secs = value.get("secs").and_then(Value::as_u64)?; let nanos = value.get("nanos").and_then(Value::as_u64).unwrap_or(0); - Some(secs * 1000 + nanos / 1_000_000) + Some(secs.saturating_mul(1000).saturating_add(nanos / 1_000_000)) } /// A Codex item status as an ACP one. diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs b/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs index aab2abb562bb..206d374a4839 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/adopt.rs @@ -270,7 +270,6 @@ async fn adopt_replays_a_codex_turns_commands_and_file_edits() { .replace("{ID}", &chat) .replace("{CWD}", &project.to_string_lossy()); std::fs::write(&rollout, fixture).unwrap(); - backdate(&rollout); let s = c.request(method::SESSION_NEW, adopt("fakecodex", &chat)).await.unwrap(); let id = s["sessionId"].as_str().unwrap().to_owned(); @@ -392,7 +391,6 @@ async fn adopt_replays_a_failed_codex_patch_without_its_edits() { ]; let text: String = lines.iter().map(|l| format!("{l}\n")).collect(); std::fs::write(&rollout, text).unwrap(); - backdate(&rollout); let s = c.request(method::SESSION_NEW, adopt("fakecodex", &chat)).await.unwrap(); let id = s["sessionId"].as_str().unwrap().to_owned(); @@ -525,7 +523,6 @@ async fn adopt_replays_a_claude_turns_tool_calls() { std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); std::fs::write(&transcript, lines.iter().map(|l| format!("{l}\n")).collect::()) .unwrap(); - backdate(&transcript); let (hub, mut c) = super::fork_through::claude_setup(&claude, &[]).await; let s = c.request(method::SESSION_NEW, adopt("claude", &chat)).await.unwrap(); From d3496ae95a239760cd405b8ff56fdc796bc4b42e Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 04:15:27 -0700 Subject: [PATCH 11/12] cmux-next: Outputs matches a last-turn file to an output by exact path first Review on #22166: a turn file's bare name ('index.ts') or a checkpoint's root file ('package.json') matched any output ending in it, so a same-named file in another folder lost its row and opened the wrong turn's diff. A turn file now takes the output with its own path, and a checkpoint file the shortest output path ending in its path. Co-Authored-By: Claude Opus 5.5 --- .../acpmux/summary/SummaryBody.tsx | 25 ++++++++--- webviews/test/sources-files-once.test.tsx | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx index c82876d0f3de..7b4933cfefaf 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx @@ -64,15 +64,30 @@ export function SummaryBody({ // A file the last turn changed opens that turn's diff, also when an earlier turn's edit tool made // it an output: its last change is the one to show. const lastTurn = changes?.files ?? []; - const rows = [ + const named = new Set(summary.outputs.map((output) => output.path)); + // The output a last-turn file is: the one with its path, else (a checkpoint file, under the repository + // root) the shortest output path ending in its path, so `index.ts` in one folder never takes another + // folder's `index.ts`, nor a root `package.json` a package's. + const outputOf = (file: (typeof lastTurn)[number]) => + named.has(file.path) + ? file.path + : summary.outputs + .filter((output) => changedByTools(file, [output])) + .reduce((best, output) => (!best || output.path.length < best.length ? output.path : best), undefined); + const turnOf = new Map(); + const checkpointOnly: (typeof lastTurn)[number][] = []; + for (const file of lastTurn) { + const output = outputOf(file); + if (output === undefined) checkpointOnly.push(file); + else if (!turnOf.has(output)) turnOf.set(output, file); + } + const outputs = [ ...summary.outputs.map((output) => { - const turn = lastTurn.find((file) => changedByTools(file, [output])); + const turn = turnOf.get(output.path); return turn ? { file: turn, inTurnDiff: true } : { file: output, inTurnDiff: false }; }), - ...lastTurn.filter((file) => !changedByTools(file, summary.outputs)).map((file) => ({ file, inTurnDiff: true })), + ...checkpointOnly.map((file) => ({ file, inTurnDiff: true })), ]; - // Two outputs the agent wrote differently (`~/x/a.ts`, `/Users/me/x/a.ts`) are one checkpoint file. - const outputs = rows.filter((row, index) => rows.findIndex((other) => other.file.path === row.file.path) === index); return ( <> { + const opened: string[] = []; + const outputsOpened: string[] = []; + // Before the checkpoint loads, a one-file turn's file reads by its bare name. + const last = { ...(file("/repo/b/index.ts", 2) as object), displayPath: "index.ts" } as never; + await sourcesRows( + [file("/repo/a/index.ts", 1), file("/repo/b/index.ts", 2)], + [last], + (path) => opened.push(path!), + (path) => outputsOpened.push(path), + ); + const buttons = [...host.querySelectorAll("section button")].filter((b) => + b.textContent?.includes("index.ts"), + ); + expect(buttons.length).toBe(2); + for (const button of buttons) await act(async () => button.click()); + expect(opened).toEqual(["/repo/b/index.ts"]); + expect(outputsOpened).toEqual(["/repo/a/index.ts"]); +}); + +// A checkpoint's root file takes the root output, not a package's file of the same name. +test("a checkpoint's root package.json is the root output, not a package's", async () => { + const opened: string[] = []; + const outputsOpened: string[] = []; + const root = { ...(file("/Users/me/repo/package.json", 1) as object), displayPath: "package.json" } as never; + await sourcesRows( + [file("~/repo/packages/x/package.json", 3), file("~/repo/package.json", 1)], + [root], + (path) => opened.push(path!), + (path) => outputsOpened.push(path), + ); + const buttons = [...host.querySelectorAll("section button")].filter((b) => + b.textContent?.includes("package.json"), + ); + expect(buttons.length).toBe(2); + for (const button of buttons) await act(async () => button.click()); + expect(opened).toEqual(["/Users/me/repo/package.json"]); + expect(outputsOpened).toEqual(["~/repo/packages/x/package.json"]); +}); From 1d3da297d729fd4d6b064c0b1f78d545c1825662 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 04:17:11 -0700 Subject: [PATCH 12/12] style: vp fmt Co-Authored-By: Claude Opus 5.5 --- webviews/src/agent-session/acpmux/summary/SummaryBody.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx index 7b4933cfefaf..5f7d39c86a57 100644 --- a/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx +++ b/webviews/src/agent-session/acpmux/summary/SummaryBody.tsx @@ -73,7 +73,10 @@ export function SummaryBody({ ? file.path : summary.outputs .filter((output) => changedByTools(file, [output])) - .reduce((best, output) => (!best || output.path.length < best.length ? output.path : best), undefined); + .reduce( + (best, output) => (!best || output.path.length < best.length ? output.path : best), + undefined, + ); const turnOf = new Map(); const checkpointOnly: (typeof lastTurn)[number][] = []; for (const file of lastTurn) {