diff --git a/webviews/src/agent-session/acpmux/App.tsx b/webviews/src/agent-session/acpmux/App.tsx index 078be7f13e4e..b7bcf807ec77 100644 --- a/webviews/src/agent-session/acpmux/App.tsx +++ b/webviews/src/agent-session/acpmux/App.tsx @@ -734,7 +734,11 @@ const SummaryRow = memo( ); const PlanRow = memo( function PlanRow({ row }: RowProps) { - return row.plan ? :
{row.text}
; + return row.plan ? ( + + ) : ( +
{row.text}
+ ); }, (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. */} +
{entries.length === 1 ? t("plan.progress.one", { done }) : t("plan.progress", { done, total: entries.length })}
    @@ -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(); +});