diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts index addfc7702427..4085f5dd4434 100644 --- a/webviews/src/agent-session/acpmux/conversation/turns.ts +++ b/webviews/src/agent-session/acpmux/conversation/turns.ts @@ -58,9 +58,8 @@ export function workedLabel(t: Translate, row: AcpmuxRow): string { return row.status === "cancelled" ? t("turn.stopped", { time }) : t("turn.worked", { time }); } -const isEdit = (row: AcpmuxRow) => - row.kind === "activity" && - (row.items ?? []).some((item) => item.tool?.kind === "edit" || item.tool?.kind === "fileChange"); +const isEditTool = (tool: { kind?: string } | undefined) => tool?.kind === "edit" || tool?.kind === "fileChange"; +const isEdit = (row: AcpmuxRow) => row.kind === "activity" && (row.items ?? []).some((item) => isEditTool(item.tool)); /// The rows to draw. `expanded` holds the ids of open disclosures; `working` says the last /// turn is still running (the snapshot's `isWorking`); `now` dates the turns (timestamps.ts); @@ -240,6 +239,24 @@ function shapedTurn( } const isAnswer = (row: AcpmuxRow) => row.kind === "assistant"; + +/// A tool call after a turn's last text: the turn ended on work, so that text was interim. +/// Edits join the turn's card and render calls draw as their own cards, so neither counts; a +/// step that edited and then ran something does. +const isWork = (row: AcpmuxRow) => + row.kind === "activity" && + (row.items ?? []).some((item) => item.tool && !isEditTool(item.tool) && !renderCall(item.tool)); + +/// Where a finished turn's answer is in `rows`: the last text, unless the turn went on to work +/// after it (-1: no answer, so all of it folds, as the Codex and Claude desktop apps fold it). +function answerAt(rows: readonly AcpmuxRow[]): number { + for (let at = rows.length - 1; at >= 0; at -= 1) { + const row = rows[at]!; + if (isAnswer(row)) return at; + if (isWork(row)) return -1; + } + return -1; +} const isUnsent = (row: AcpmuxRow) => Boolean(row.pending || row.failed); function shapeTurn( @@ -260,12 +277,7 @@ function shapeTurn( return live ? liveTurn(user, turn, clockOffset) : last ? turn : settledWithoutSummary(user, turn, expanded); const summary = turn[end]!; const body = turn.slice(0, end); - let final = -1; - for (let at = body.length - 1; at >= 0; at -= 1) - if (body[at]!.kind === "assistant") { - final = at; - break; - } + const final = answerAt(body); const answer = final >= 0 ? body[final] : undefined; // Work before the answer folds away (all of it, when the turn ended without one); edits // also close the turn as their card. Subagent groups and the todo list stay out, under the @@ -371,12 +383,7 @@ const staysOut = (row: AcpmuxRow) => /// reloaded turn without timing, and its answer and edits draw after it. function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: ReadonlySet): AcpmuxRow[] { const rows = turn.filter((row) => row.kind !== "typing"); - let final = -1; - for (let at = rows.length - 1; at >= 0; at -= 1) - if (rows[at]!.kind === "assistant") { - final = at; - break; - } + const final = answerAt(rows); const before = final >= 0 ? rows.slice(0, final) : rows; const work = before.filter((row) => !staysOut(row)); if (work.length === 0) return rows; diff --git a/webviews/test/transcript-worked-fold.test.ts b/webviews/test/transcript-worked-fold.test.ts new file mode 100644 index 000000000000..84feaf2a82f4 --- /dev/null +++ b/webviews/test/transcript-worked-fold.test.ts @@ -0,0 +1,85 @@ +// A finished turn folds everything between the prompt and its answer under "Worked for", as the +// Codex and Claude desktop apps do: the answer is the text the turn ended on. A turn that ended on +// work instead (a tool after its last text, or stopped mid-tool) has no answer, so its interim +// text folds with the rest rather than standing open as if it were the answer. +import { expect, test } from "bun:test"; +import type { AcpmuxRow } from "../src/agent-session/acpmux/model"; +import { turnView } from "../src/agent-session/acpmux/conversation/turns"; + +let at = 0; +const row = (id: string, kind: string, extra: Partial = {}): AcpmuxRow => ({ + id, + kind, + version: 1, + at: (at += 1_000), + ...extra, +}); +const tool = (id: string, kind = "read") => + row(id, "activity", { items: [{ kind: "tool", text: "", tool: { id, title: id, kind, status: "completed" } }] }); +const text = (id: string) => row(id, "assistant", { text: id }); +const summary = (status = "completed") => row("end", "turnSummary", { status, durationMs: 9_000 }); +// The turn's rows as drawn, without the day separators above them. +const drawn = (rows: AcpmuxRow[], expanded: ReadonlySet = new Set()) => + turnView(rows, expanded) + .filter((shaped) => shaped.kind !== "date") + .map((shaped) => `${shaped.kind}:${shaped.id}`); + +test("the answer stays open below the fold of the work before it", () => { + const rows = [row("u", "user"), text("interim"), tool("read"), text("answer"), summary()]; + expect(drawn(rows)).toEqual(["user:u", "worked:worked-u", "assistant:answer", "turnSummary:end"]); +}); + +test("a turn that ended on a tool folds its interim text with the rest", () => { + const rows = [row("u", "user"), text("interim-1"), tool("read"), text("interim-2"), tool("grep"), summary()]; + expect(drawn(rows)).toEqual(["user:u", "worked:worked-u", "turnSummary:end"]); +}); + +test("a turn stopped mid-tool folds its work under the stop line", () => { + const rows = [row("u", "user"), text("interim"), tool("build"), summary("cancelled")]; + expect(drawn(rows)).toEqual(["user:u", "worked:worked-u", "turnSummary:end"]); +}); + +test("a turn that edited and then ran a tool in one step folds its interim text", () => { + const step = row("step", "activity", { + items: [ + { kind: "tool", text: "", tool: { id: "patch", title: "patch", kind: "edit", status: "completed" } }, + { kind: "tool", text: "", tool: { id: "test", title: "test", kind: "execute", status: "completed" } }, + ], + }); + const rows = [row("u", "user"), text("interim"), step, summary("cancelled")]; + const shaped = drawn(rows); + expect(shaped.slice(0, 2)).toEqual(["user:u", "worked:worked-u"]); + expect(shaped).not.toContain("assistant:interim"); +}); + +test("an edit after the answer still joins the turn's edit card, and the answer stays open", () => { + const rows = [row("u", "user"), tool("read"), text("answer"), tool("patch", "edit"), summary()]; + const shaped = drawn(rows); + expect(shaped.slice(0, 3)).toEqual(["user:u", "worked:worked-u", "assistant:answer"]); + expect(shaped.at(-1)).toBe("turnSummary:end"); + expect(shaped.filter((entry) => entry === "activity:patch")).toHaveLength(1); +}); + +test("an earlier turn without a summary that ended on a tool folds its interim text too", () => { + const rows = [ + row("u1", "user"), + text("interim"), + tool("read"), + row("u2", "user"), + text("answer-2"), + row("end-2", "turnSummary", { status: "completed", durationMs: 1_000 }), + ]; + const shaped = drawn(rows); + expect(shaped.slice(0, shaped.indexOf("user:u2"))).toEqual(["user:u1", "worked:worked-u1"]); +}); + +test("expanding the fold of a turn that ended on a tool shows the interim text and tools in order", () => { + const rows = [row("u", "user"), text("interim"), tool("read"), summary()]; + expect(drawn(rows, new Set(["worked-u"]))).toEqual([ + "user:u", + "worked:worked-u", + "assistant:interim", + "activity:read", + "turnSummary:end", + ]); +});