+ );
},
(a, b) => a.row.id === b.row.id && a.row.version === b.row.version,
);
diff --git a/webviews/src/agent-session/acpmux/composerStates.css b/webviews/src/agent-session/acpmux/composerStates.css
index 8bc596c15d7c..a0fd53f3ef34 100644
--- a/webviews/src/agent-session/acpmux/composerStates.css
+++ b/webviews/src/agent-session/acpmux/composerStates.css
@@ -90,10 +90,12 @@
/* Composer chips: Plan, the mode, model and effort pickers, +, the mic and Steer. A press keeps the
hover's pill, with no brighter or tinted tone, and no shrink or dip (Leo 2026-10-19: no mousedown
and mouseup click feedback). The press shows at once (cg-composer click polish, as Codex's
- chips): the hover's ease below never delays the button going down. */
+ chips): the hover's ease below never delays the button going down. The Plan toggle's rules name
+ the button: the transcript's todo list row is `.acpmux-plan` too (App.tsx's
+ `acpmux-row acpmux-${kind}`). */
.acpmux-picker-button:active,
.acpmux-picker-button[data-press-active],
-.acpmux-plan:active,
+:where(button).acpmux-plan:active,
.acpmux-steer:active,
.acpmux-mic:active,
.acpmux-fast:active,
@@ -209,7 +211,7 @@ button.acpmux-send:disabled:not(.acpmux-cancel):not(.acpmux-send-ready) {
/* Hover and press colors ease, and the picker and slash menus fade in; with Reduce Motion they apply at once. */
@media (prefers-reduced-motion: no-preference) {
.acpmux-picker-button,
- .acpmux-plan,
+ :where(button).acpmux-plan,
.acpmux-steer {
transition:
background-color var(--agent-motion-hover) var(--agent-ease-out),
diff --git a/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts b/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts
index fb5a8a77cb56..e79bf3108a7d 100644
--- a/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts
+++ b/webviews/src/agent-session/acpmux/conversation/PlanList.gallery.ts
@@ -1,6 +1,6 @@
// l10n-allow-file: gallery fixtures (sample prompts, replies and todo items), not shipped UI.
// The agent's todo list (PlanList.tsx, cx-ncc.80): Claude's TodoWrite or Codex's plan draws as a
-// checklist under its progress, outside a finished turn's "Worked for" fold, and live while the
+// quiet card, outside a finished turn's "Worked for" fold, and live while the
// turn runs.
import { agentPaneEntry } from "../../../gallery/format";
import { assistant, chat, row, summary, user } from "../../../gallery/fixtures/acpmux";
@@ -16,7 +16,7 @@ export default agentPaneEntry({
covers: ["agent-session/acpmux/conversation/PlanList.tsx"],
variants: {
running: {
- note: "A turn under way: one item done, one in progress (bold), one waiting.",
+ note: "A turn under way: one item done (filled check), one in progress (spinner), one waiting (dashed circle).",
snapshot: chat([
user("Add retry to the uploader, cover it and update the docs", 3),
row("plan", 2, {
diff --git a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx
index 96e67515d75e..ffd470684ba6 100644
--- a/webviews/src/agent-session/acpmux/conversation/PlanList.tsx
+++ b/webviews/src/agent-session/acpmux/conversation/PlanList.tsx
@@ -1,7 +1,8 @@
// The agent's todo list (cx-ncc.80, Claude parity): an ACP `plan` update (Claude's TodoWrite through
-// acpmux, or Codex's own plan) draws as a checklist under its progress, and a turn's later updates
-// redraw its one list (direct.ts). Done items are struck through, the one in progress is bold; a
-// screen reader hears each item's state.
+// acpmux, or Codex's own plan) draws as a quiet card, and a turn's later updates redraw its one list
+// (direct.ts). As in Claude desktop, the marks carry the state: a filled check is done, a thin
+// spinner runs and a dashed circle waits, with no heading, strike-through or bold. Once the turn
+// ends a step left in progress stops spinning. A screen reader hears the progress and each state.
import { useT, type StringKey } from "../i18n";
import type { AcpmuxPlanEntry } from "../model";
import { Check } from "./icons";
@@ -12,12 +13,13 @@ const STATUS: Record = {
pending: "plan.status.pending",
};
-export function PlanList({ entries }: { entries: readonly AcpmuxPlanEntry[] }) {
+export function PlanList({ entries, ended = false }: { entries: readonly AcpmuxPlanEntry[]; ended?: boolean }) {
const t = useT();
const done = entries.filter((entry) => entry.status === "completed").length;
return (
-
-
+
+ {/* Heard, not drawn: the marks show the progress. */}
+
@@ -29,7 +31,7 @@ export function PlanList({ entries }: { entries: readonly AcpmuxPlanEntry[] }) {
{entry.status === "completed" && }
{entry.content}
- {/* The marks and strike-through are drawn only; a screen reader hears the state. */}
+ {/* The marks are drawn only; a screen reader hears the state. */}
{t(STATUS[entry.status])}
))}
diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css
index 022c70d3e3b8..e184cfdf8491 100644
--- a/webviews/src/agent-session/acpmux/conversation/conversation.css
+++ b/webviews/src/agent-session/acpmux/conversation/conversation.css
@@ -2520,16 +2520,17 @@ pre.cv-math-source {
backdrop-filter: none;
}
-/* The agent's todo list (PlanList.tsx, cx-ncc.80). */
+/* The agent's todo list (PlanList.tsx, cx-ncc.80): a quiet card, as in Claude desktop. A filled
+ check is done, a thin spinner runs and a dashed circle waits; no heading, strike-through or bold. */
.cv-plan {
+ position: relative;
margin: 8px 0;
+ padding: 8px 12px;
+ border-radius: 10px;
+ background: var(--cv-card-fill);
+ box-shadow: inset 0 0 0 1px var(--cv-card-ring);
font-size: var(--text-body);
}
-.cv-plan__heading {
- height: 20px;
- font-size: var(--text-caption);
- color: var(--cv-text-muted);
-}
.cv-plan__items {
margin: 0;
padding: 0;
@@ -2537,9 +2538,16 @@ pre.cv-math-source {
}
.cv-plan__item {
display: flex;
- align-items: center;
+ align-items: flex-start;
gap: 8px;
- min-height: 22px;
+ min-height: 24px;
+ padding-block: 4px;
+ box-sizing: border-box;
+ line-height: 16px;
+}
+.cv-plan__text {
+ min-width: 0;
+ overflow-wrap: anywhere;
}
.cv-plan__mark {
flex: 0 0 auto;
@@ -2549,10 +2557,12 @@ pre.cv-math-source {
width: 12px;
height: 12px;
border: 1.5px solid var(--cv-text-muted);
+ margin-top: 2px;
border-radius: 50%;
box-sizing: border-box;
}
-/* The item's state for screen readers only (the pane does not load ui.css's .ui-sr-only). */
+/* The progress and each item's state for screen readers only (the pane does not load ui.css's
+ .ui-sr-only). */
.cv-plan__state {
position: absolute;
width: 1px;
@@ -2563,18 +2573,37 @@ pre.cv-math-source {
}
.cv-plan__item[data-status="completed"] {
color: var(--cv-text-muted);
- text-decoration: line-through;
}
.cv-plan__item[data-status="completed"] .cv-plan__mark {
border-color: transparent;
background: var(--cv-text-muted);
color: var(--agent-accent-text, #fff);
}
-.cv-plan__item[data-status="in_progress"] {
- font-weight: 600;
+.cv-plan__item[data-status="pending"] .cv-plan__mark {
+ border-style: dashed;
}
+/* A thin arc on a faint ring; it turns while the turn runs. */
.cv-plan__item[data-status="in_progress"] .cv-plan__mark {
- border-color: var(--agent-text);
+ border-color: var(--cv-card-ring);
+ border-top-color: var(--agent-text);
+}
+/* Left in progress when its turn ended: a plain ring, not an arc frozen mid-turn. */
+.cv-plan[data-ended] .cv-plan__item[data-status="in_progress"] .cv-plan__mark {
+ border-color: var(--cv-text-muted);
+}
+/* Static status indicators and Reduce Motion: the arc holds still. */
+@media (prefers-reduced-motion: no-preference) {
+ :root:not([data-status-indicators="static"])
+ .cv-plan:not([data-ended])
+ .cv-plan__item[data-status="in_progress"]
+ .cv-plan__mark {
+ animation: cv-plan-spin 0.9s linear infinite;
+ }
+}
+@keyframes cv-plan-spin {
+ to {
+ transform: rotate(1turn);
+ }
}
/* ---------- A background shell's end (BackgroundTaskNote.tsx, cx-ncc.84) ---------- */
diff --git a/webviews/src/agent-session/acpmux/conversation/turns.ts b/webviews/src/agent-session/acpmux/conversation/turns.ts
index 58b4261b2177..4b2e9c15aadb 100644
--- a/webviews/src/agent-session/acpmux/conversation/turns.ts
+++ b/webviews/src/agent-session/acpmux/conversation/turns.ts
@@ -290,7 +290,7 @@ function shapeTurn(
return live
? liveTurn(user, turn, clockOffset, liveRuns)
: last
- ? turn
+ ? turn.map(endedPlan)
: settledWithoutSummary(user, turn, expanded);
const summary = turn[end]!;
const body = turn.slice(0, end);
@@ -325,9 +325,9 @@ function shapeTurn(
if (open)
shaped.push(...work.map((row) => ({ ...row, id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true })));
}
- shaped.push(...groups, ...renderRows(body, version));
+ shaped.push(...groups.map(endedPlan), ...renderRows(body, version));
if (answer) shaped.push(answer);
- shaped.push(...rest, ...editsCard(edits));
+ shaped.push(...rest.map(endedPlan), ...editsCard(edits));
const preview = turnPreviewUrl(user, turn);
if (preview) shaped.push({ id: `${PREVIEW}-${user.id}`, version, at: summary.at, kind: PREVIEW, text: preview });
// The footer copies the answer, so it carries the answer's text; the last turn's also retries
@@ -341,7 +341,8 @@ function shapeTurn(
});
// Anything after the summary (late tool updates, or a turn the agent started on its own)
// draws as it came.
- shaped.push(...turn.slice(end + 1));
+ // A turn the agent started on its own while still working keeps its plan live.
+ shaped.push(...turn.slice(end + 1).map(live ? (row) => row : endedPlan));
return shaped;
}
@@ -406,6 +407,11 @@ const staysOut = (row: AcpmuxRow) =>
// What the person answered an agent's question, which shaped the rest of the turn.
row.kind === QUESTION_RECORD;
+/// A finished turn's todo list: a step the agent left in progress no longer spins. The version
+/// moves off the live one (v to 2v + 1), so the memoized row draws again when the turn ends.
+const endedPlan = (row: AcpmuxRow): AcpmuxRow =>
+ row.kind === "plan" && row.plan ? { ...row, turnEnded: true, version: row.version * 2 + 1 } : row;
+
/// A turn that ended without a summary (history paged in from before acpmux kept one, or a
/// turn a restart cut off): its work folds under "N previous messages", like any
/// reloaded turn without timing, and its answer and edits draw after it.
@@ -414,7 +420,7 @@ function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: Rea
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;
+ if (work.length === 0) return rows.map(endedPlan);
const id = `${WORKED}-${user.id}`;
const open = expanded.has(id);
const edits = work.filter(isEdit);
@@ -423,8 +429,8 @@ function settledWithoutSummary(user: AcpmuxRow, turn: AcpmuxRow[], expanded: Rea
return [
{ id, version: version * 2 + (open ? 1 : 0), at: user.at, kind: WORKED, previous },
...(open ? work.map((row) => ({ ...row, id: isEdit(row) ? `${row.id}${FOLDED}` : row.id, settled: true })) : []),
- ...before.filter(staysOut),
- ...(final >= 0 ? rows.slice(final) : []),
+ ...before.filter(staysOut).map(endedPlan),
+ ...(final >= 0 ? rows.slice(final).map(endedPlan) : []),
...editsCard(edits),
];
}
diff --git a/webviews/src/agent-session/acpmux/model.ts b/webviews/src/agent-session/acpmux/model.ts
index 6e43ad70e9bd..0b6afb632f7b 100644
--- a/webviews/src/agent-session/acpmux/model.ts
+++ b/webviews/src/agent-session/acpmux/model.ts
@@ -150,6 +150,8 @@ export type AcpmuxRow = {
prompt?: string;
/// An edited-files card of a turn that has ended, which offers Undo (conversation/turns.ts).
ended?: boolean;
+ /// A todo list whose turn has ended: a step left in progress no longer spins (conversation/turns.ts).
+ turnEnded?: boolean;
/// The edits an edited-files card merges (their row ids), so Find reaches each (chatFind.ts).
merged?: string[];
/// A shell mode command's block (shell/shellRuns.ts), which the page adds; never from acpmux.
@@ -554,8 +556,8 @@ function fallbackRowHeight(row: AcpmuxRow, width: number): number {
if (row.kind === "agentPair") return row.agentPair?.resumed ? 37 : 37 + 24;
// A session start: its facts line, and the compaction note under it until it is dismissed.
if (row.kind === "sessionStart") return 37 + 44;
- // The plan's 20px progress line over its 22px items, inside 8px margins (conversation/PlanList.tsx).
- if (row.kind === "plan" && row.plan) return 24 + 20 + row.plan.length * 22;
+ // The plan card's 24px items inside 8px of padding and 8px margins (conversation/PlanList.tsx).
+ if (row.kind === "plan" && row.plan) return 40 + row.plan.length * 24;
if (
row.kind === "turnSummary" ||
row.kind === "notice" ||
diff --git a/webviews/src/agent-session/acpmux/styles.css b/webviews/src/agent-session/acpmux/styles.css
index c70754e6965c..b1852b6a2887 100644
--- a/webviews/src/agent-session/acpmux/styles.css
+++ b/webviews/src/agent-session/acpmux/styles.css
@@ -76,12 +76,12 @@
.acpmux-picker-button{display:flex;align-items:center;gap:4px;min-width:0;height:28px;padding:0 8px;border:0;border-radius:6px;background:none;color:var(--agent-text);white-space:nowrap}
.acpmux-picker-button>span{overflow:hidden;text-overflow:ellipsis}
.acpmux-picker-button[aria-expanded=true]{background:var(--acpmux-pill-hover,color-mix(in srgb,var(--agent-text) 11%,transparent));box-shadow:none}@media (hover:hover) and (pointer:fine){.acpmux-picker-button:hover{background:var(--acpmux-pill-hover,color-mix(in srgb,var(--agent-text) 11%,transparent))}}
-.acpmux-picker-button:focus-visible,.acpmux-plan:focus-visible,.acpmux-send:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:2px}.acpmux-model .acpmux-picker-button:focus-visible,.acpmux-effort .acpmux-picker-button:focus-visible,.acpmux-access .acpmux-picker-button:focus-visible{background:var(--acpmux-pill-hover,color-mix(in srgb,var(--agent-text) 11%,transparent));box-shadow:none}
+.acpmux-picker-button:focus-visible,:where(button).acpmux-plan:focus-visible,.acpmux-send:focus-visible{outline:1.5px solid var(--agent-text);outline-offset:2px}.acpmux-model .acpmux-picker-button:focus-visible,.acpmux-effort .acpmux-picker-button:focus-visible,.acpmux-access .acpmux-picker-button:focus-visible{background:var(--acpmux-pill-hover,color-mix(in srgb,var(--agent-text) 11%,transparent));box-shadow:none}
.acpmux-mode .acpmux-picker-button{gap:6px;padding:0 8px 0 6px;color:var(--agent-muted)}
-.acpmux-plan{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 6px;border:0;border-radius:6px;background:none;color:var(--agent-muted);font:inherit;cursor:default;white-space:nowrap}
-.acpmux-plan:hover{background:var(--acpmux-composer-hover);color:var(--agent-text)}
+:where(button).acpmux-plan{display:flex;align-items:center;gap:6px;height:28px;padding:0 8px 0 6px;border:0;border-radius:6px;background:none;color:var(--agent-muted);font:inherit;cursor:default;white-space:nowrap}
+:where(button).acpmux-plan:hover{background:var(--acpmux-composer-hover);color:var(--agent-text)}
/* Plan on is accent ink with no pill at rest (Leo 2026-10-19); hover, focus and a press draw the chips' soft pill. */
-.acpmux-plan[aria-pressed=true]{color:var(--agent-accent,var(--agent-text))}
+:where(button).acpmux-plan[aria-pressed=true]{color:var(--agent-accent,var(--agent-text))}
.acpmux-separator{flex:none;width:1px;height:18px;margin:0 4px;background:var(--acpmux-composer-edge)}
.acpmux-chips>.acpmux-plan+.acpmux-chips-spacer,.acpmux-chips>.acpmux-mode+.acpmux-chips-spacer{margin-left:0}
/* Full access draws in the chip's own color, like Claude's mode label: the shield's mark names the risk and the menu marks its icon (Leo 2026-10-18). */
diff --git a/webviews/test/agent-pane-plan.test.ts b/webviews/test/agent-pane-plan.test.ts
index 00fcc14647cd..0b781b10c96b 100644
--- a/webviews/test/agent-pane-plan.test.ts
+++ b/webviews/test/agent-pane-plan.test.ts
@@ -173,7 +173,8 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => {
await page.getByText("All three done.").waitFor();
const lists = await page.evaluate(() =>
[...document.querySelectorAll(".cv-plan")].map((list) => ({
- heading: list.querySelector(".cv-plan__heading")?.textContent,
+ // Heard, not drawn, like each item's state: the marks show the progress.
+ progress: list.querySelector(".cv-plan__progress")?.textContent,
items: [...list.querySelectorAll(".cv-plan__item")].map((item) => [
item.querySelector(".cv-plan__text")?.textContent,
item.getAttribute("data-status"),
@@ -190,7 +191,7 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => {
).toBe(true);
expect(lists).toEqual([
{
- heading: "2 of 3 tasks done",
+ progress: "2 of 3 tasks done",
items: [
["Add retry to the uploader", "completed", "Done"],
["Cover it with a test", "completed", "Done"],
@@ -199,6 +200,50 @@ await requireBrowserLane("agent-pane-plan.test.ts", async () => {
},
]);
+ // Claude desktop's card: the marks carry the state, with no strike-through or bold, and the
+ // step the agent left in progress stops spinning once its turn has ended.
+ expect(
+ await page.evaluate(() =>
+ [...document.querySelectorAll(".cv-plan__item")].map((item) => {
+ const style = getComputedStyle(item);
+ return [style.textDecorationLine, style.fontWeight];
+ }),
+ ),
+ ).toEqual([
+ ["none", "400"],
+ ["none", "400"],
+ ["none", "400"],
+ ]);
+ expect(
+ await page
+ .locator('.cv-plan__item[data-status="in_progress"] .cv-plan__mark')
+ .evaluate((mark) => getComputedStyle(mark).animationName),
+ ).toBe("none");
+ expect(await page.locator(".cv-plan").evaluate((list) => getComputedStyle(list).boxShadow)).not.toBe("none");
+
+ await page.close();
+ });
+ test("while the turn runs, the step in progress spins and the next one waits as a dashed circle", async () => {
+ const page = await browser!.newPage({ viewport: { width: 900, height: 760 } });
+ await page.goto(url(LIVE));
+ await page.getByText("Adding retry now.").waitFor();
+ const mark = (status: string) =>
+ page.locator(`.cv-plan__item[data-status="${status}"] .cv-plan__mark`).evaluate((node) => {
+ const style = getComputedStyle(node);
+ return { animation: style.animationName, border: style.borderTopStyle };
+ });
+ // Every step shows: the row is not held to the composer Plan toggle's 28px (.acpmux-plan).
+ expect(
+ await page.evaluate(() => {
+ const row = document.querySelector(".acpmux-row:has(.cv-plan)")!.getBoundingClientRect();
+ return [...document.querySelectorAll(".cv-plan__item")].every((item) => {
+ const box = item.getBoundingClientRect();
+ return box.top >= row.top && box.bottom <= row.bottom;
+ });
+ }),
+ ).toBe(true);
+ expect((await mark("in_progress")).animation).toBe("cv-plan-spin");
+ expect(await mark("pending")).toEqual({ animation: "none", border: "dashed" });
await page.close();
});
test("while the turn runs, the checklist stands for its TodoWrite calls", async () => {
diff --git a/webviews/test/static-status-loops.test.ts b/webviews/test/static-status-loops.test.ts
index 02f96176ddbb..a6e6344e6d9d 100644
--- a/webviews/test/static-status-loops.test.ts
+++ b/webviews/test/static-status-loops.test.ts
@@ -3,8 +3,8 @@
// `data-status-indicators="static"` on the root: the app's WebTheme bootstrap sets it from the theme
// payload (`statusIndicators`), which this test runs as the app does, and under it, or under Reduce
// Motion, no web loop may run: the mic's wait pulse, the model picker and harness spinners, the
-// floating chat's breathing dot, the Thinking sweep, the running line's working dots, the reply
-// caret's wait pulse, the Jump to
+// floating chat's breathing dot, the Thinking sweep, the running line's working dots, the todo
+// list's spinner, the reply caret's wait pulse, the Jump to
// Latest dots and Home's "writing" pulse. The diff viewer does not get the pane's theme, so its
// spinners stop under Reduce Motion alone. Static also gives the color-only marks a shape each.
// This loads the real stylesheets in Chromium and lists the animations that never end.
@@ -50,6 +50,7 @@ await requireBrowserLane("static-status-loops.test.ts", async () => {
`
` +
`
` +
`` +
+ `
` +
`writing`;
const diffLoops = `
Loading
`;
@@ -110,6 +111,8 @@ await requireBrowserLane("static-status-loops.test.ts", async () => {
const running = await loops(p);
await p.close();
expect(running.length).toBeGreaterThanOrEqual(10);
+ // The todo list's spinner among them, so the Static and Reduce Motion checks cover it.
+ expect(running.some((loop) => loop.endsWith("cv-plan-spin"))).toBe(true);
});
test("Static status indicators stop every pane loop", async () => {
diff --git a/webviews/test/transcript-plan-ended.test.ts b/webviews/test/transcript-plan-ended.test.ts
new file mode 100644
index 000000000000..88f2dd732b52
--- /dev/null
+++ b/webviews/test/transcript-plan-ended.test.ts
@@ -0,0 +1,59 @@
+// A finished turn's todo list stops spinning (PlanList's `ended`): every way a turn ends marks
+// its plan row `turnEnded`, and only a running turn's plan stays live.
+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 plan = (id: string) => row(id, "plan", { plan: [{ content: "Run the tests", status: "in_progress" }] });
+const summary = (id: string) => row(id, "turnSummary", { status: "completed" });
+
+const planRow = (rows: AcpmuxRow[], id: string, working = false) =>
+ turnView(rows, new Set(), { working }).find((shaped) => shaped.id === id);
+
+test("a running turn's plan stays live", () => {
+ expect(planRow([row("u", "user"), plan("p")], "p", true)?.turnEnded).toBeUndefined();
+});
+
+test("the last turn without a summary, no longer working, ends its plan", () => {
+ expect(planRow([row("u", "user"), plan("p")], "p")?.turnEnded).toBe(true);
+});
+
+test("an earlier turn without a summary or other work ends its plan", () => {
+ const rows = [row("u1", "user"), plan("p"), row("u2", "user"), row("a2", "assistant", { text: "ok" })];
+ expect(planRow(rows, "p")?.turnEnded).toBe(true);
+});
+
+test("a plan sent after the answer ends with the turn", () => {
+ const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), plan("p"), summary("s")];
+ expect(planRow(rows, "p")?.turnEnded).toBe(true);
+});
+
+test("a plan arriving after the summary ends with the turn", () => {
+ const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), summary("s"), plan("p")];
+ expect(planRow(rows, "p")?.turnEnded).toBe(true);
+});
+
+test("an earlier turn without a summary ends a plan sent after its answer", () => {
+ const rows = [
+ row("u1", "user"),
+ row("t1", "thought", { text: "Reading the branch" }),
+ row("a1", "assistant", { text: "done" }),
+ plan("p"),
+ row("u2", "user"),
+ row("a2", "assistant", { text: "ok" }),
+ ];
+ expect(planRow(rows, "p")?.turnEnded).toBe(true);
+});
+
+test("a turn the agent started on its own after the summary keeps its plan live", () => {
+ const rows = [row("u", "user"), row("a", "assistant", { text: "done" }), summary("s"), plan("p")];
+ expect(planRow(rows, "p", true)?.turnEnded).toBeUndefined();
+});