Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 22 additions & 15 deletions webviews/src/agent-session/acpmux/conversation/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -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<string>): 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;
Expand Down
85 changes: 85 additions & 0 deletions webviews/test/transcript-worked-fold.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<string> = 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",
]);
});
Loading