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
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// l10n-allow-file: gallery fixtures (sample prompts, commands and replies), not shipped UI.
// One finished turn per harness family, folded as its row of turnFoldStyle.ts says (Leo, 2026-11-04:
// "do it for chatgpt, do the proper folds for however the other harnessses/model families fold
// (muse, gemini)", then, on Claude: "i think it should be the top level collapsible thing IF
// POSSIBLE... SOMETIMES there's just claude chatter that we could prob group up"). The same turn
// (two runs of calls, the chatter between them, then the conclusion) draws as ChatGPT's "Worked for
// 12m 16s ›" over the reply for Codex and Claude, opened as Claude desktop's per-run lines for
// Claude, and in place with no turn fold for Gemini and Muse.
import { agentPaneEntry, type AgentPaneVariant } from "../../../gallery/format";
import { activity, assistant, chat, summary, tool, user } from "../../../gallery/fixtures/acpmux";
import type { Play } from "../../../gallery/play";
import type { AcpmuxActivity } from "../model";

const run = (description: string, command: string): AcpmuxActivity =>
tool(command, "execute", "completed", { description, command, output: "ok\n", exitCode: 0 });

function turn() {
return [
user("Make the fetch helper stop retrying client errors", 20),
activity(
[
run("Found the retry loop", "rg -n 'retry' src/net/fetchWithRetry.ts"),
run("Listed every caller", "rg -l fetchWithRetry src"),
run("Read the retry tests", "sed -n '1,60p' src/net/fetchWithRetry.test.ts"),
],
19,
),
assistant("The loop retries any response that isn't ok, so I'm adding a status check there.", 15),
activity(
[
run("Ran the retry tests", "bun test src/net"),
run("Checked the branch is clean", "git status --short"),
],
10,
),
assistant(
"Done. The helper now fails a 4xx at once, except 408 and 429, which still retry; a new test covers a 404.",
8,
),
summary(7.8, { status: "completed", durationMs: 736_000, toolCount: 5 }),
];
}

const snapshot = (family: string, harness: string) => {
const base = chat(turn(), { title: "Stop retrying client errors", harness });
return { ...base, summary: { ...base.summary!, family } };
};

/// Opens the turn's "Worked for" line.
const openWorked: Play = async (ctx) => {
const worked = () => ctx.document.querySelector<HTMLElement>(".cv-worked");
await ctx.waitFor(worked, { capMs: 10_000 });
if (worked()?.getAttribute("aria-expanded") === "false") {
await ctx.click({ selector: ".cv-worked" });
await ctx.waitFor(() => worked()?.getAttribute("aria-expanded") === "true", { capMs: 10_000 });
}
};

const variant = (family: string, harness: string, note: string, play?: Play): AgentPaneVariant => ({
note,
snapshot: snapshot(family, harness),
...(play && { play }),
});

export default agentPaneEntry({
id: "agent-pane.turn-fold-per-harness",
title: "Turn fold per harness family",
area: "Agent pane",
height: 640,
widths: { narrow: 520, normal: 760, wide: 900 },
covers: ["agent-session/acpmux/conversation/turnFoldStyle.ts", "agent-session/acpmux/conversation/turns.ts"],
variants: {
claude: variant(
"claude",
"claude",
"Claude, chosen: ChatGPT's closed “Worked for 12m 16s” over the conclusion; the runs and the chatter between them fold under it.",
),
"claude-open": variant(
"claude",
"claude",
"Claude, opened: each run folds into its own line with the chatter between them, as Claude desktop draws a turn.",
openWorked,
),
codex: variant(
"codex",
"codex",
"Codex and ChatGPT: the same closed fold; with messages between the work, Codex's header counts them.",
),
gemini: variant(
"gemini",
"gemini",
"Gemini CLI: no turn fold; each run of calls folds into its own line in place, as its tool boxes group them.",
),
muse: variant(
"muse",
"muse",
"Muse Code: no reference of how its app ends a turn, so no turn fold; per-run lines in place.",
),
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// A finished Codex turn's header, as Codex's own (Leo, 2026-11-02: "i assume the x previous messages
// are when there's a mix and the worked for is if it's one contiguous thing ... let's follow those"):
// work with the agent's messages between it reads "N previous messages", work alone "Worked for".
// Claude turns keep their per-run folding (#30176) and draw no header.
// Claude turns fold under ChatGPT's "Worked for" and never count messages (turnFoldStyle.ts).
import { expect, test } from "bun:test";
import type { AcpmuxRow } from "../model";
import { WORKED, turnView, workedLabel } from "./turns";
Expand Down Expand Up @@ -63,8 +63,8 @@ test("work with the agent's messages between it reads N previous messages", () =
});

// Leo, 2026-11-04: "ofc claude code doesn't actually have the worked for... but i think we should be
// able to do that replacing the top level thingy maybe".
test("a Claude turn draws the same header; its runs fold inside it", () => {
// able to do that replacing the top level thingy maybe"; ChatGPT's header reads "Worked for 12m 16s".
test("a Claude turn reads Worked for, never Codex's message count", () => {
const rows = [
prompt,
work("w1", 1_000, 2),
Expand All @@ -73,8 +73,9 @@ test("a Claude turn draws the same header; its runs fold inside it", () => {
say("a", 50_000, "Done."),
end,
];
expect(header(rows, "claude")?.previous).toBeGreaterThan(0);
expect(header(rows, "claude")?.previous).toBe(header(rows)?.previous);
const row = header(rows, "claude");
expect(row?.previous).toBeUndefined();
expect(workedLabel(t, row!)).toBe("Worked for 1m");
});

test("a message joining the fold redraws the header: its count is part of the row's version", () => {
Expand Down
128 changes: 128 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/turnFoldStyle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// l10n-allow-file: test fixtures.
// One finished turn per harness family, folded as its table row says (turnFoldStyle.ts). Leo,
// 2026-11-04: "do it for chatgpt, do the proper folds for however the other harnessses/model
// families fold (muse, gemini)", then, on Claude: "i think it should be the top level collapsible
// thing IF POSSIBLE".
import { expect, test } from "bun:test";
import type { AcpmuxRow } from "../model";
import { WORKED, turnView } from "./turns";
import { TURN_FOLD_STYLES, turnFoldStyle } from "./turnFoldStyle";

const call = (id: string) => ({
kind: "tool",
text: "Run",
tool: { id, title: "Run", kind: "execute", status: "completed" },
});
const prompt: AcpmuxRow = { id: "p", version: 1, at: 0, kind: "user", text: "Fix it" };
const work = (id: string, at: number, n: number) =>
({
id,
version: 1,
at,
kind: "activity",
items: Array.from({ length: n }, (_, i) => call(`${id}-${i}`)),
}) as AcpmuxRow;
const say = (id: string, at: number, text: string): AcpmuxRow => ({ id, version: 1, at, kind: "assistant", text });
const end: AcpmuxRow = {
id: "s",
version: 1,
at: 60_000,
kind: "turnSummary",
status: "completed",
durationMs: 60_000,
};
const NOTE =
"Found the cause in the parser: it reads the trailing comma as the start of another item, so every list " +
"that ends a line gets an empty entry, and the retry loop then treats that entry as a failed request.";
// Two runs of calls with chatter between them, then the conclusion.
const finished = [
prompt,
work("w1", 1_000, 2),
// Longer than a short note, so it stands between the runs instead of joining one (mergeShortNotes).
say("n1", 10_000, NOTE),
work("w2", 20_000, 3),
say("a", 50_000, "Done: the parser keeps the trailing comma now."),
end,
];
const drawn = (agentFamily: string, open: ReadonlySet<string> = new Set()) =>
turnView(finished, open, { now: 70_000, agentFamily }).filter((row) => row.kind !== "user");
const ids = (rows: readonly AcpmuxRow[]) => rows.map((row) => row.id);

test("the table is the one place a family's fold is named", () => {
expect(Object.keys(TURN_FOLD_STYLES).sort()).toEqual(["claude", "codex", "gemini", "muse"]);
// Variants share their agent's row, and an unknown family folds as Codex does.
expect(turnFoldStyle("claude-sr2")).toBe(TURN_FOLD_STYLES.claude!);
expect(turnFoldStyle("muse-code")).toBe(TURN_FOLD_STYLES.muse!);
expect(turnFoldStyle("opencode")).toBe(TURN_FOLD_STYLES.codex!);
expect(turnFoldStyle(undefined)).toBe(TURN_FOLD_STYLES.codex!);
});

test("codex: one closed header over the conclusion, counting the messages it folds", () => {
const rows = drawn("codex");
const worked = rows.find((row) => row.kind === WORKED)!;
expect(worked.foldOpen).toBe(false);
expect(worked.previous).toBe(6);
expect(ids(rows.filter((row) => row.kind !== "turnSummary"))).toEqual([worked.id, "a"]);
});

test("claude: Worked for over the conclusion, the chatter and runs folded under it", () => {
const rows = drawn("claude");
const worked = rows.find((row) => row.kind === WORKED)!;
expect(worked.foldOpen).toBe(false);
// ChatGPT's header: always "Worked for", never Codex's message count.
expect(worked.previous).toBeUndefined();
expect(worked.durationMs).toBe(60_000);
expect(ids(rows.filter((row) => row.kind !== "turnSummary"))).toEqual([worked.id, "a"]);
expect(rows.find((row) => row.kind === "turnSummary")?.folded).toBe(true);
});

test("claude: opened, its runs fold per run with the chatter between them, as Claude desktop's", () => {
const closed = drawn("claude").find((row) => row.kind === WORKED)!;
const rows = drawn("claude", new Set([closed.id]));
const runs = rows.filter((row) => row.kind === "activity");
expect(runs.length).toBe(2);
expect(runs.every((row) => row.foldRun === true)).toBe(true);
expect(ids(rows)).toContain("n1");
});

test("claude: a turn that ends on a call keeps its last takeaway in sight, the call folded", () => {
const takeaway = say(
"t",
30_000,
"The parser drops the trailing comma when the list ends a line, which is why every request looked failed " +
"to the retry loop; I'm sending the fix to the lane that owns the parser now and will check it lands.",
);
const rows = turnView([prompt, work("w1", 1_000, 2), takeaway, work("w2", 40_000, 1), end], new Set(), {
now: 70_000,
agentFamily: "claude",
});
const worked = rows.find((row) => row.kind === WORKED)!;
expect(worked.foldOpen).toBe(false);
expect(ids(rows.filter((row) => row.kind !== "turnSummary" && row.kind !== "user"))).toEqual([worked.id, "t"]);
});

test("claude: a turn with only its reply draws no Worked for", () => {
const rows = turnView([prompt, say("a", 5_000, "Yes."), end], new Set(), { now: 70_000, agentFamily: "claude" });
expect(rows.some((row) => row.kind === WORKED)).toBe(false);
expect(ids(rows)).toContain("a");
});

test("claude: a running turn draws its work as it happens, no Worked for yet", () => {
const rows = turnView([prompt, work("w1", 1_000, 2), say("n1", 10_000, "Looking.")], new Set(), {
now: 20_000,
agentFamily: "claude",
working: true,
});
expect(rows.some((row) => row.kind === WORKED)).toBe(false);
expect(ids(rows)).toContain("n1");
});

for (const family of ["gemini", "muse"])
test(`${family}: no turn fold; each run folds into its own line in place, the chatter between them`, () => {
const rows = drawn(family);
expect(rows.some((row) => row.kind === WORKED)).toBe(false);
const order = ids(rows.filter((row) => row.kind !== "turnSummary"));
expect(order).toEqual(["w1", "n1", "w2", "a"]);
expect(rows.filter((row) => row.kind === "activity").every((row) => row.foldRun === true)).toBe(true);
expect(rows.find((row) => row.kind === "turnSummary")?.folded).toBe(false);
});
47 changes: 47 additions & 0 deletions webviews/src/agent-session/acpmux/conversation/turnFoldStyle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// How a finished turn folds, per harness family: one table, read once per turn by turns.ts.
//
// Leo, 2026-11-04: "doesn't make sense to fold claude's transcripts like that. do it for chatgpt,
// do the proper folds for however the other harnessses/model families fold (muse, gemini)", then:
// "i think it should be the top level collapsible thing IF POSSIBLE... SOMETIMES there's just claude
// chatter that we could prob group up". Spec: leo-workspace design/chat-transcript.md "Turn fold
// per harness family".
import { agentKey } from "../../shared/agentKey";

export type TurnFoldStyle = {
/// A finished turn's work, and the interim chatter between it, goes under one closed "Worked for"
/// line with only the conclusion (its last reply after the last call) in sight, as ChatGPT and
/// Codex end a turn. False: no turn-level line; the work stays where it ran.
conclusion: boolean;
/// Each run of calls, the short notes inside it with it, folds into its own summary line ("Ran 3
/// commands"), as Claude desktop draws a turn: inside an open "Worked for", or in place where
/// the turn has none. False: the calls draw one under another (a running turn's live runs aside).
runs: boolean;
/// The closed line counts the fold's messages, "6 previous messages", when the agent's own
/// messages sit between its work, as Codex's header does. False: it always reads "Worked for Ns",
/// as ChatGPT's.
countsMessages: boolean;
};

/// ChatGPT and Codex: the conclusion fold, under Codex's own header.
const CODEX: TurnFoldStyle = { conclusion: true, runs: false, countsMessages: true };

/// Keyed by the harness family's agent (agentKey: "claude-sr2" is "claude"). A family missing here
/// folds as Codex does, as every family but Claude did before this table.
export const TURN_FOLD_STYLES: Readonly<Record<string, TurnFoldStyle>> = {
codex: CODEX,
// Claude: ChatGPT's "Worked for 12m 16s ›" over the conclusion; opened, its runs fold per run as
// Claude desktop's do (cmux#30176), so the chatter between them reads in place.
claude: { conclusion: true, runs: true, countsMessages: false },
// Gemini CLI draws a turn as it ran, with each run of calls grouped in one tool box and no
// turn-level fold: per-run lines, no "Worked for".
gemini: { conclusion: false, runs: true, countsMessages: false },
// Muse Code: no reference of how its app ends a turn, so no turn fold (the per-run lines Claude
// turns had before), until one shows otherwise.
muse: { conclusion: false, runs: true, countsMessages: false },
};

/// The fold style of a harness family (acpmux's summary `family`); undefined folds as Codex does.
export function turnFoldStyle(family: string | undefined): TurnFoldStyle {
const key = agentKey(family);
return (key && Object.hasOwn(TURN_FOLD_STYLES, key) ? TURN_FOLD_STYLES[key] : undefined) ?? CODEX;
}
Loading
Loading