diff --git a/TODO.md b/TODO.md index df7bd35c..e0c954a1 100644 --- a/TODO.md +++ b/TODO.md @@ -21,41 +21,41 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Themes -**92 open, 29 closed**, across 31 themes. +**91 open, 30 closed**, across 31 themes. -| Theme | Open | Closed | -| ------------------------------------------------------------------------------------------ | ---- | ------ | -| [Affordances](todo/affordances/) | 2 | 0 | -| [Agents coordinating agents](todo/agent-coordination/) | 2 | 0 | -| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 | -| [Backburner](todo/backburner/) | 3 | 0 | -| [Background jobs](todo/background-jobs/) | 2 | 1 | -| [Chat interface](todo/chat/) | 1 | 0 | -| [Code editor](todo/code-editor/) | 1 | 0 | -| [Components — the shared UI layer](todo/components/) | 1 | 0 | -| [File watchers](todo/file-watchers/) | 1 | 0 | -| [Fleet — one command across every server](todo/fleet/) | 5 | 2 | -| [Headscale](todo/headscale/) | 0 | 5 | -| [Infra (alpha)](todo/infra/) | 7 | 0 | -| [Known bugs](todo/known-bugs/) | 4 | 2 | -| [Mobile apps](todo/mobile-apps/) | 3 | 0 | -| [Multi-user](todo/multi-user/) | 16 | 5 | -| [Notifications](todo/notifications/) | 1 | 0 | -| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 | -| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 | -| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | -| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | -| [Prompt library](todo/prompt-library/) | 1 | 0 | -| [SendOff](todo/sendoff/) | 1 | 0 | -| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 | -| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 | -| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 | -| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 | -| [Tooling — the list about the list](todo/tooling/) | 0 | 1 | -| [Transmission](todo/transmission/) | 6 | 0 | -| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 | -| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 | -| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 | +| Theme | Open | Closed | +| --- | --- | --- | +| [Affordances](todo/affordances/) | 2 | 0 | +| [Agents coordinating agents](todo/agent-coordination/) | 2 | 0 | +| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 | +| [Backburner](todo/backburner/) | 3 | 0 | +| [Background jobs](todo/background-jobs/) | 2 | 1 | +| [Chat interface](todo/chat/) | 1 | 0 | +| [Code editor](todo/code-editor/) | 1 | 0 | +| [Components — the shared UI layer](todo/components/) | 1 | 0 | +| [File watchers](todo/file-watchers/) | 1 | 0 | +| [Fleet — one command across every server](todo/fleet/) | 5 | 2 | +| [Headscale](todo/headscale/) | 0 | 5 | +| [Infra (alpha)](todo/infra/) | 7 | 0 | +| [Known bugs](todo/known-bugs/) | 3 | 3 | +| [Mobile apps](todo/mobile-apps/) | 3 | 0 | +| [Multi-user](todo/multi-user/) | 16 | 5 | +| [Notifications](todo/notifications/) | 1 | 0 | +| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 3 | 0 | +| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 0 | +| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | +| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | +| [Prompt library](todo/prompt-library/) | 1 | 0 | +| [SendOff](todo/sendoff/) | 1 | 0 | +| [Settings — portable, and readable by an agent](todo/settings/) | 2 | 0 | +| [Shell — spotlight and the dock](todo/shell/) | 4 | 1 | +| [Sub-agents — agents spawning agents](todo/sub-agents/) | 0 | 2 | +| [Sudo terminal modal](todo/sudo-terminal-modal/) | 1 | 0 | +| [Tooling — the list about the list](todo/tooling/) | 0 | 1 | +| [Transmission](todo/transmission/) | 6 | 0 | +| [URL state — routing, query strings and selection](todo/url-state/) | 4 | 0 | +| [Waiting on the owner to run it](todo/waiting-on-owner/) | 3 | 0 | +| [Workspaces & Panels](todo/workspaces-panels/) | 2 | 0 | ## Records — no items, kept for the reasoning @@ -68,7 +68,6 @@ the owner's OS user and can never be granted. Indirection there really is accide - `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md) - `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md) -- `2026-08-25` **Refreshing a finished chat turn loses output — replay collapses to a fold and the closing message vanishes** — [known-bugs/refreshing-a-finished-chat-turn-loses-output](todo/known-bugs/refreshing-a-finished-chat-turn-loses-output.md) - `2026-08-23` **A worker cannot verify that the owner delegated to a manager, so it has to ask every time** — [agent-coordination/a-peer-cannot-verify-a-delegation](todo/agent-coordination/a-peer-cannot-verify-a-delegation.md) - `2026-08-23` **An agent cannot learn its own transcript id, so it cannot sign its own work** — [agent-coordination/an-agent-cannot-learn-its-own-session-id](todo/agent-coordination/an-agent-cannot-learn-its-own-session-id.md) - `2026-08-23` **The SendOff file size limit is one constant for everybody** — [sendoff/per-user-size-limit](todo/sendoff/per-user-size-limit.md) @@ -76,3 +75,4 @@ the owner's OS user and can never be granted. Indirection there really is accide - `2026-08-22` **Revisit the bun 1.3 pin once 1.4 has been watched for a while** — [infra/revisit-the-bun-1-3-pin-once-1-4-has-been](todo/infra/revisit-the-bun-1-3-pin-once-1-4-has-been.md) - `2026-08-22` **A plugin's permission should be on for every role by default, off only on request** — [multi-user/a-plugin-s-permission-should-be-on-for-every](todo/multi-user/a-plugin-s-permission-should-be-on-for-every.md) - `2026-08-22` **A user's settings should export and import as a plain file** — [settings/a-user-s-settings-should-export-and-import-as](todo/settings/a-user-s-settings-should-export-and-import-as.md) +- `2026-08-22` **The same for system settings, owned by the Super Admin** — [settings/the-same-for-system-settings-owned-by-the](todo/settings/the-same-for-system-settings-owned-by-the.md) diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 45daf7a5..a5fb0b00 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -423,7 +423,17 @@ export type ClaudeChatMessage = // Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation // above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to // "why did it go quiet for two minutes there", which is only useful if it survives a reload. - | { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true }; + | { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true } + // A background task reporting in — see `taskNotificationFrom`. `startsTurn` is the load-bearing field: + // this is the prompt the agent woke up to, so it is where one turn ends and the next begins. + | { + role: 'task'; + taskId: string; + description: string; + status?: 'completed' | 'failed' | 'stopped'; + summary?: string; + startsTurn: true; + }; /** * Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the @@ -457,16 +467,59 @@ function blockText(content: unknown): string { * A background task reporting in. The harness delivers these to the agent by writing them as the *user's* * next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally, * the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never - * typed. Live it never appears, because a finished task travels as its own `task:notification` event; it - * surfaces only when a refresh rebuilds the conversation from Claude's file. + * typed. Live it never appears as prose, because a finished task travels as its own `task:notification` + * event and lands in the row the task already had; it surfaces only when a refresh rebuilds the + * conversation from Claude's file. * * Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real * message — this file's own conversation does exactly that — stays yours. */ const isTaskNotification = (text: string) => text.trimStart().startsWith(''); +const tagOf = (envelope: string, tag: string): string | undefined => + envelope.match(new RegExp(`<${tag}>([\\s\\S]*?)`))?.[1]; + +/** `&` last, or `&lt;` would decode twice and become `<`. */ +const decodeEntities = (text: string) => + text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); + +const TASK_STATUSES = new Set(['completed', 'failed', 'stopped']); + +/** + * The envelope, rebuilt into the same row a live `task:notification` produces when the task's announcing + * row is gone (`useChat`, `task:notification`). It used to be dropped outright, and dropping it lost more + * than a bubble: **turn boundaries are derived from the user's messages**, so deleting one merged the + * woken turn into the one before it. Everything the agent had said closing that earlier turn stopped being + * the turn's trailing prose and became mid-turn work, which folds — and the merged turn then ended on a + * tool call, which printed "Turn finished · no closing message" over a turn that had plainly finished + * talking. A commander wakes on these, so its whole life replayed as a single turn with every answer + * folded away. + * + * Malformed envelopes still return null and are still dropped: a row we cannot identify is worse than a + * missing boundary. + */ +function taskNotificationFrom(text: string): ClaudeChatMessage | null { + const taskId = tagOf(text, 'task-id')?.trim(); + if (!taskId) return null; + const status = tagOf(text, 'status')?.trim(); + const summary = decodeEntities((tagOf(text, 'summary') ?? '').trim()); + return { + role: 'task', + taskId, + description: summary, + status: status && TASK_STATUSES.has(status) ? (status as 'completed' | 'failed' | 'stopped') : undefined, + summary, + startsTurn: true, + }; +} + function userMessageFrom(text: string): ClaudeChatMessage | null { - if (isTaskNotification(text)) return null; + if (isTaskNotification(text)) return taskNotificationFrom(text); if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' }; return { role: 'user', text }; } diff --git a/src/servers/api/chat/task-notification-replay.test.ts b/src/servers/api/chat/task-notification-replay.test.ts new file mode 100644 index 00000000..b0523ab7 --- /dev/null +++ b/src/servers/api/chat/task-notification-replay.test.ts @@ -0,0 +1,91 @@ +import { afterAll, describe, expect, test } from 'bun:test'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { loadClaudeSession, projectSlug, type ChatIdentity } from './claude-sessions'; + +/** + * The harness delivers a finished background task to the agent by writing it as the agent's next *user* + * message. Replayed literally that is a raw XML envelope in a bubble on your side of the conversation, so + * the parser used to drop it — and dropped the turn boundary with it, because `turn-rows` derives turns + * from the message list alone. Reported 2026-08-25 against a commander, which wakes on these: its whole + * history replayed as one turn with every answer folded away inside the seam. + * + * Driven through the real loader rather than the parser, because the drop and the boundary live one call + * apart and testing them separately is how they came apart in the first place. + */ + +const CWD = '/home/someone/commanders/1'; +const SESSION = '00000000-0000-4000-8000-000000000001'; + +const home = mkdtempSync(join(tmpdir(), 'officer-transcript-')); +afterAll(() => rmSync(home, { recursive: true, force: true })); + +const who: ChatIdentity = { email: 'someone@example.com', home, osUser: null, isOwner: true }; + +const write = (lines: unknown[]): void => { + const dir = join(home, '.claude', 'projects', projectSlug(CWD)); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${SESSION}.jsonl`), lines.map((l) => JSON.stringify(l)).join('\n')); +}; + +const load = () => loadClaudeSession(who, CWD, SESSION)!.messages; + +const NOTIFICATION = [ + '', + 'b99iu40k1', + 'toolu_01JDH', + 'completed', + 'Background command "wc -l < ledger.tsv" completed (exit code 0)', + '', +].join('\n'); + +const say = (text: string) => ({ + type: 'assistant', + cwd: CWD, + message: { role: 'assistant', content: [{ type: 'text', text }] }, +}); +const prompt = (text: string) => ({ type: 'user', cwd: CWD, message: { role: 'user', content: text } }); + +describe('a background task reporting in, replayed from the transcript', () => { + test('becomes a task row rather than being dropped or shown as something you typed', () => { + write([prompt('watch the folder'), say('armed'), prompt(NOTIFICATION), say('it fired')]); + + const messages = load(); + expect(messages.map((m) => m.role)).toEqual(['user', 'assistant', 'task', 'assistant']); + + const task = messages[2]; + expect(task).toMatchObject({ role: 'task', taskId: 'b99iu40k1', status: 'completed', startsTurn: true }); + }); + + test('carries the summary with its entities decoded, not the raw envelope', () => { + write([prompt('watch the folder'), prompt(NOTIFICATION)]); + + const task = load()[1] as Extract[number], { role: 'task' }>; + expect(task.summary).toBe('Background command "wc -l < ledger.tsv" completed (exit code 0)'); + expect(task.summary).not.toContain('<'); + expect(task.summary).not.toContain(''); + }); + + test('an envelope with no task id is still dropped — an unidentifiable row is worse than none', () => { + write([ + prompt('watch the folder'), + prompt('\ncompleted\n'), + ]); + + expect(load().map((m) => m.role)).toEqual(['user']); + }); + + test('a message merely QUOTING an envelope stays yours', () => { + // This file's own conversation does exactly that, which is why the match is anchored to the start. + write([prompt(`what does ${NOTIFICATION} mean?`)]); + + expect(load().map((m) => m.role)).toEqual(['user']); + }); + + test('an unknown status is dropped rather than passed through to the row', () => { + write([prompt('go'), prompt(NOTIFICATION.replace('completed', 'wedged'))]); + + expect(load()[1]).toMatchObject({ role: 'task', status: undefined }); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts index 4f329dbe..6be9047e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts @@ -14,6 +14,16 @@ const tool = (id: string, isError?: boolean): ChatMessage => ({ output: 'ok', ...(isError ? { isError: true } : {}), }); +/** A tool call we never saw come back — no `output`. The turn is still holding it. */ +const pending = (id: string): ChatMessage => ({ role: 'tool', toolName: 'Bash', toolInput: {}, toolCallId: id }); +/** A finished background task the agent woke up to — see `startsTurn` in types.ts. */ +const woke = (id: string): ChatMessage => ({ + role: 'task', + taskId: id, + description: 'the task that woke it', + status: 'completed', + startsTurn: true, +}); /** * The shape under test is the row sequence, not the keys. @@ -243,6 +253,86 @@ describe('buildRows', () => { test('a bare question with no work yet produces no marker and no fold', () => { expect(shape([user('go')], true)).toEqual(['user']); }); + + // ── A turn the agent woke up to ── + // + // The bug these pin, reported 2026-08-25 against a commander: the harness delivers a finished background + // task as the agent's next *user* message, and the transcript parser used to drop that message so it would + // not render as an XML envelope you appear to have typed. Dropping it also dropped the turn boundary, so + // every turn a commander woke up for merged into the one before it. A commander's whole life replayed as + // one turn, with every answer it had ever given folded away inside the seam. + + test('a task the agent woke up to starts a turn, and the answer before it stays visible', () => { + expect(shape([user('go'), tool('a'), say('done, watching'), woke('t1'), tool('b'), say('it fired')])).toEqual([ + 'user', + 'fold', + 'assistant', + 'task', + 'fold', + 'assistant', + ]); + }); + + test('the same messages WITHOUT the flag are what used to swallow the answer', () => { + const merged: ChatMessage[] = [ + user('go'), + tool('a'), + say('done, watching'), + { role: 'task', taskId: 't1', description: 'fired', status: 'completed' }, + tool('b'), + say('it fired'), + ]; + expect(shape(merged)).toEqual(['user', 'fold', 'assistant']); + }); + + test('a task the agent STARTED mid-turn is work, not a boundary', () => { + const started: ChatMessage[] = [ + user('go'), + { role: 'task', taskId: 't1', description: 'building' }, + say('off it goes'), + ]; + expect(shape(started)).toEqual(['user', 'fold', 'assistant']); + }); + + test('the woken turn is never folded away — it is the question that turn answers', () => { + const rows = buildRows([user('go'), say('watching'), woke('t1'), tool('b'), say('it fired')], false, 5); + expect(rows[2]?.kind === 'message' && rows[2].message.role).toBe('task'); + }); + + // The reported screenshot, in miniature. Merged, the segment ended on a tool call, so the answer became + // mid-turn work and folded, and the marker was stamped over the whole conversation. Split, the answer is + // back on screen and the marker — still earned, by a woken turn that really did say nothing — is confined + // to the two rows it describes. + test('merged, the answer is swallowed and the marker covers everything', () => { + const merged: ChatMessage[] = [ + user('go'), + say('armed'), + { role: 'task', taskId: 't1', description: 'fired', status: 'completed' }, + tool('b'), + ]; + expect(shape(merged)).toEqual(['user', 'fold', 'turn-end']); + }); + + test('split, the answer survives and the marker describes only the woken turn', () => { + expect(shape([user('go'), say('armed'), woke('t1'), tool('b')])).toEqual([ + 'user', + 'assistant', + 'task', + 'fold', + 'turn-end', + ]); + }); + + // ── A tool we never saw come back ── + + test('a turn holding an unfinished tool call is not marked finished', () => { + expect(shape([user('go'), pending('a')], false, 0)).toEqual(['user', 'tool']); + }); + + test('a reload mid-turn does not claim the turn finished before the socket answers', () => { + // `isGenerating` is false until `attach` comes back, so for those frames the transcript is all we have. + expect(shape([user('go'), tool('a'), pending('b')])).toEqual(['user', 'fold']); + }); }); /** What tool rows key their expansion off. Getting this wrong expands a whole transcript at once. */ diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts index cf3397b9..fa2915cd 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts @@ -106,10 +106,12 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR const push = (message: ChatMessage, i: number) => rows.push({ kind: 'message', key: keyOf(message, offset + i), message, live }); - // Your message is never folded — it is the question the whole turn is an answer to. + // Whatever prompted the turn is never folded — it is the question the whole turn is an answer to. Usually + // that is your message; for a turn the agent woke up to, it is the task that woke it. let start = 0; - if (segment[0]?.role === 'user') { - push(segment[0], 0); + const opener = segment[0]; + if (opener && (opener.role === 'user' || (opener.role === 'task' && opener.startsTurn))) { + push(opener, 0); start = 1; } @@ -151,8 +153,24 @@ function buildTurn({ segment, offset, fold, complete, live }: TurnParams): ChatR flush(); } + // A tool we never saw come back. The turn is then not over — it is still running, or it was cut off + // holding that call — and either way "finished without saying anything" is a claim the messages do not + // support. It matters most on a reload, which renders from the transcript with `isGenerating` still + // false until the socket attaches: for those few frames every unfinished turn looks finished. + // + // A COMPLETED tool call at the end is a different thing and keeps its marker: the tool came back, the + // agent said nothing, and that is exactly the silence this row exists to name. + const last = segment.at(-1); + const toolStillOut = last?.role === 'tool' && last.output === undefined; + // Nothing was said and nothing went wrong — say so, rather than ending on a collapsed tool call. - if (complete && !tail.some(isAnswer) && !tail.some(isFailure) && (head.length > 0 || tail.length > 0)) { + if ( + complete && + !toolStillOut && + !tail.some(isAnswer) && + !tail.some(isFailure) && + (head.length > 0 || tail.length > 0) + ) { rows.push({ kind: 'turn-end', key: `e:${offset}` }); } @@ -178,15 +196,20 @@ export function buildRows(messages: ChatMessage[], isGenerating: boolean, histor // half `complete` — tool calls, no answer — and printed "Turn finished · no closing message" across a // turn that was visibly still working, with the header still saying so. Reported as "the output is very // misleading", and it was: the UI said the opposite of what was happening. - const userAt: number[] = []; + // + // A background task reporting in is the other kind of prompt, and it starts a turn for the same reason: the + // agent was asleep and this is what it woke up to. Live that boundary needs no stating, because the work + // arrives as it happens; on a reload it has to be reconstructed, which is what `startsTurn` is for. + const turnAt: number[] = []; messages.forEach((m, i) => { - if (m.role === 'user' && !m.injected) userAt.push(i); + if (m.role === 'user' && !m.injected) turnAt.push(i); + else if (m.role === 'task' && m.startsTurn) turnAt.push(i); }); - // Every user message is a turn boundary — including the last one, which used to be excluded because the + // Every prompt is a turn boundary — including the last one, which used to be excluded because the // turn after it was by definition the live one. Now the final segment is an ordinary turn that folds or // not on the same rule as the rest. - const bounds = [0, ...userAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]); + const bounds = [0, ...turnAt.filter((i) => i > 0), messages.length].filter((v, i, a) => i === 0 || v !== a[i - 1]); const rows: ChatRow[] = []; for (let i = 0; i < bounds.length - 1; i++) { diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index ed22f0c5..e8269ef6 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -77,6 +77,17 @@ export type ChatMessage = taskType?: string; status?: 'completed' | 'failed' | 'stopped'; summary?: string; + /** + * This row is the notification the agent WOKE UP to, not a task it started mid-turn — so it opens a + * turn, the way a user message does. Only the transcript parser sets it, because only a reload has to + * reconstruct a boundary the live socket never needed to state. + * + * The mirror image of `injected` on a user message: that one is a message that does not start a turn, + * this is a non-message that does. Both exist because `turn-rows` derives turns from the message list + * alone, and both were added after that derivation printed "Turn finished · no closing message" over a + * turn that had not finished. + */ + startsTurn?: true; } | { // The harness rewriting its own context, start to finish, in one row — the same shape as `task`, diff --git a/todo/known-bugs/refreshing-a-finished-chat-turn-loses-output.md b/todo/known-bugs/refreshing-a-finished-chat-turn-loses-output.md index fb87c899..23e64fcd 100644 --- a/todo/known-bugs/refreshing-a-finished-chat-turn-loses-output.md +++ b/todo/known-bugs/refreshing-a-finished-chat-turn-loses-output.md @@ -2,7 +2,7 @@ id: known-bugs/refreshing-a-finished-chat-turn-loses-output title: 'Refreshing a finished chat turn loses output — replay collapses to a fold and the closing message vanishes' theme: known-bugs -status: open +status: done created: 2026-08-25 date_source: stated order: 4 @@ -35,3 +35,47 @@ of exactly this class of bug in comments and tests), `TurnFold.tsx:88` (renders whatever writes the durable events the replay reads — the claude sidecar has owned `chat_session_events` writes since the isolation work. Note `0 turns · 0 tok · $0` suggests the commander's turn ledger also saw none of it, which points the same direction: the durable record, not the renderer. + +--- + +**Fixed 2026-08-25. The durable record was innocent, and so was the fold.** A refresh never reads +`chat_session_events` at all: `GET /chat/sessions/:id` parses Claude's `.jsonl`, and `attach` sets the +replay cursor to the head precisely so the durable log is NOT re-delivered on top of it. Both symptoms came +from one line in the transcript parser. + +The harness delivers a finished background task to the agent by writing it as the agent's next **user** +message — a `` envelope. `claude-sessions.ts` dropped that message, so it would not +replay as a raw XML bubble on your side of the conversation. Dropping it also dropped a turn boundary, +because `turn-rows` derives turns from the message list and a turn starts at a user message. So the woken +turn merged into the one before it, and everything that followed: + +- the earlier turn's closing prose stopped being the turn's trailing run and became mid-turn work, which + **folds** — that is the "3 messages" behind a click, prose the agent wrote, hidden; +- the merged segment then ended on a tool call, so the exposed tail was empty and + `TURN FINISHED · NO CLOSING MESSAGE` was stamped over a turn that had answered. + +A commander wakes on these, so its whole life replayed as **one** turn. Measured on the reported session +(`331fee09`, Voice Memos): 30 parsed messages, **one** user message, one fold hiding **seven** answers, and +the banner reproduced at 21 of the 30 points a refresh could land on — which is the "a lot of times". + +The fix is in three parts. The parser rebuilds the envelope into the same `task` row a live +`task:notification` produces, carrying `startsTurn: true` (entities decoded; a malformed envelope with no +task id is still dropped). `buildRows` treats such a row as a turn boundary and `buildTurn` exposes it as +the turn's opener, the way it exposes your question. And the marker is withheld when the segment ends on a +tool call **we never saw come back** — that turn is still running or was cut off, and a reload renders with +`isGenerating` false until the socket attaches. A COMPLETED trailing tool call keeps its marker; the +existing test pinning that live behaviour passes unchanged. + +Verified by replaying the real transcript through the real parser and row builder: two turns, each ending +with its own closing message on screen, no banner. 13 tests added +(`turn-rows.test.ts`, `task-notification-replay.test.ts` — the latter drives the real loader, because the +drop and the boundary live one call apart and that is how they came apart). + +**The two clues in the report above were both red herrings, worth recording.** The durable log is not the +replay source. And `0 turns · 0 tok · $0` was transient: `command_hq_turns` now holds **2** turns and +$0.92 for this commander — which independently confirms the session really is two turns while the replay +was drawing one. + +**Still open, and a product decision rather than a defect:** point 1 above. Within a correctly-bounded +turn, mid-turn prose still folds; only the trailing run is exposed. That is the documented intent +("question, summary, answer"), and it is untouched here.