From 403eb3a7f230b635b3575b955f92141f69c14929 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 14:55:45 -0700 Subject: [PATCH 01/12] test(harness): failing tests for empty-final-step recovery ladder (spec cases 1-4, 6, 7) Co-Authored-By: Claude Fable 5 --- desktop/tests/harness-session-loop.test.ts | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/desktop/tests/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts index abcd5fa2..94f217d4 100644 --- a/desktop/tests/harness-session-loop.test.ts +++ b/desktop/tests/harness-session-loop.test.ts @@ -1703,3 +1703,122 @@ describe('ModelSearch attachment mirrors Task\'s gate (Task 14)', () => { expect(toolNames(s)).toContain('ModelSearch'); }); }); + +// Empty-step recovery (spec: docs/active/specs/2026-08-21-empty-final-step- +// turn-recovery-design.md, §6). A step with no text and no tool calls that +// claims an orderly finish gets ONE silent re-run; a second consecutive empty +// step ends the turn honestly as 'empty_response'. History must never gain an +// empty assistant message, and usage must bill every attempt. +describe('HarnessSession — empty final step recovery', () => { + it('case 1: empty final step after a tool result → ONE silent re-run → real content → end_turn', async () => { + const read = fakeTool('Read'); + const seen: any[] = []; + const model = scriptedModel([ + stream(...textChunks('a', 'reading'), toolCallChunk('c1', 'Read', { file_path: 'x.ts' }), finishChunk('tool-calls')), + stream(finishChunk('stop')), // the degenerate empty step + stream(...textChunks('b', 'recovered'), finishChunk('stop')), // the silent re-run's real answer + ], seen); + const session = new HarnessSession(makeOpts({ tools: [read], decide: async () => ALLOW }), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(3); // exactly ONE extra model call + const done = events.find((e) => e.type === 'turn-complete')!; + expect(done.data.stopReason).toBe('end_turn'); + expect(events.filter((e) => e.type === 'assistant-text').map((e) => e.data.text)).toEqual(['reading', 'recovered']); + // History is exactly user / assistant(text+call) / tool / assistant(text) — + // the empty step contributed NOTHING (that is what makes the re-run safe). + const history = (session as any).history as any[]; + expect(history.map((m) => m.role)).toEqual(['user', 'assistant', 'tool', 'assistant']); + expect(JSON.stringify(history.at(-1))).toContain('recovered'); + }); + + it('case 2: empty twice consecutively → empty_response; usage sums BOTH attempts; no empty history', async () => { + const seen: any[] = []; + const model = scriptedModel([ + stream(finishChunk('stop', 10, 2)), + stream(finishChunk('stop', 11, 3)), + ], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); // bounded: two attempts, never a third + const done = events.find((e) => e.type === 'turn-complete')!; + expect(done.data.stopReason).toBe('empty_response'); + expect(done.data.usage).toMatchObject({ inputTokens: 21, outputTokens: 5 }); // both attempts billed + // Neither empty step pushed an assistant message. + expect(((session as any).history as any[]).map((m) => m.role)).toEqual(['user']); + }); + + it('case 3: counter resets on a non-empty step — a later empty step gets its own retry', async () => { + const read = fakeTool('Read'); + const seen: any[] = []; + const model = scriptedModel([ + stream(finishChunk('stop')), // empty #1 → retry + stream(...textChunks('a', 'ok'), toolCallChunk('c1', 'Read', { file_path: 'x.ts' }), finishChunk('tool-calls')), // real step → counter resets + stream(finishChunk('stop')), // empty #2 → retry AGAIN (consecutive semantics) + stream(...textChunks('b', 'done'), finishChunk('stop')), + ], seen); + const session = new HarnessSession(makeOpts({ tools: [read], decide: async () => ALLOW }), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(4); // both empties retried — the counter reset in between + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('end_turn'); + expect((read as any).calls).toHaveLength(1); + }); + + it('case 4: first-step empty (no tools all turn) → same ladder', async () => { + const seen: any[] = []; + const model = scriptedModel([ + stream(finishChunk('stop')), + stream(...textChunks('a', 'hello'), finishChunk('stop')), + ], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); + const done = events.find((e) => e.type === 'turn-complete')!; + expect(done.data.stopReason).toBe('end_turn'); + expect(events.filter((e) => e.type === 'assistant-text').map((e) => e.data.text)).toEqual(['hello']); + }); + + it('case 6: empty step with finishReason length → NO retry, ends max_tokens', async () => { + // The finishReason gate: 'length' means truncation — a retry would hit the + // same output limit, so today's mapStopReason path must be kept EXACTLY. + // NOTE: this test passes BEFORE the production change too — it is the + // regression pin that proves the new code does not widen past the gate. + const seen: any[] = []; + const model = scriptedModel([stream(finishChunk('length'))], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(1); // no retry + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('max_tokens'); + }); + + it('case 7: interrupt during the retry attempt → user-interrupt wins, no turn-complete', async () => { + // Same direct-mock pattern as the postSteer tests above (including the + // `let session!:` definite-assignment declaration): a per-call side effect + // fires the interrupt while the RETRY attempt (call 2) is running. + let session!: HarnessSession; + let call = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + call++; + if (call === 2) session.interrupt(); + return { stream: simulateReadableStream({ chunks: stream(finishChunk('stop')) }) }; + }, + }); + session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(call).toBe(2); // the retry attempt DID start… + expect(types(events)).toContain('user-interrupt'); // …but the interrupt won + expect(types(events)).not.toContain('turn-complete'); // never 'empty_response' + }); +}); From 9ca7901bb704fadf18405be679a6e91c5318e863 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 14:56:19 -0700 Subject: [PATCH 02/12] test(harness): reasoningChunks scripted-stream helper + failing reasoning-only-step test (spec case 5) Co-Authored-By: Claude Fable 5 --- desktop/tests/harness-session-loop.test.ts | 27 +++++++++++++++++++++- desktop/tests/helpers/scripted-model.ts | 13 +++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/desktop/tests/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts index 94f217d4..55e741c5 100644 --- a/desktop/tests/harness-session-loop.test.ts +++ b/desktop/tests/harness-session-loop.test.ts @@ -19,7 +19,7 @@ import type { AskRequest, AskDecision } from '../src/main/harness/permission-bro // Scripted-mock builders live in a shared helper — the history-rebuild test // (Task 10) drives the same mock model so its deep-equal contract exercises the // exact grouping this suite pins. -import { textChunks, toolCallChunk, toolInputChunks, finishChunk, stream, scriptedModel } from './helpers/scripted-model'; +import { textChunks, toolCallChunk, toolInputChunks, finishChunk, stream, scriptedModel, reasoningChunks } from './helpers/scripted-model'; // Direct MockLanguageModelV4 construction — only the postSteer tests below need // a per-call SIDE EFFECT (posting a steer from inside doStream) that the // scripted-model helpers don't support; everything else in this suite goes @@ -1785,6 +1785,31 @@ describe('HarnessSession — empty final step recovery', () => { expect(events.filter((e) => e.type === 'assistant-text').map((e) => e.data.text)).toEqual(['hello']); }); + it('case 5: reasoning-only step is classified empty and retried; history untouched', async () => { + // StepResult has NO reasoning field (spec §3) — a step that thinks and then + // stops is loop-indistinguishable from total silence, and BY DESIGN gets the + // same retry: nothing was pushed to history, so the re-run is history-safe. + const seen: any[] = []; + const model = scriptedModel([ + stream(...reasoningChunks('r1', 'pondering'), finishChunk('stop')), + stream(...textChunks('a', 'answer'), finishChunk('stop')), + ], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); // retried despite having streamed thinking + // The thinking WAS emitted to the transcript (stays on screen — accepted cost). + expect(events.some((e) => e.type === 'assistant-thinking' && e.data.text === 'pondering')).toBe(true); + const done = events.find((e) => e.type === 'turn-complete')!; + expect(done.data.stopReason).toBe('end_turn'); + // History: user + the ONE real assistant answer. The reasoning-only attempt + // pushed nothing (the push gates on text/toolCalls only). + const history = (session as any).history as any[]; + expect(history.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(JSON.stringify(history[1])).toContain('answer'); + }); + it('case 6: empty step with finishReason length → NO retry, ends max_tokens', async () => { // The finishReason gate: 'length' means truncation — a retry would hit the // same output limit, so today's mapStopReason path must be kept EXACTLY. diff --git a/desktop/tests/helpers/scripted-model.ts b/desktop/tests/helpers/scripted-model.ts index 97672583..d4aface6 100644 --- a/desktop/tests/helpers/scripted-model.ts +++ b/desktop/tests/helpers/scripted-model.ts @@ -32,6 +32,19 @@ export function multiDeltaTextChunks(id: string, ...texts: string[]) { ]; } +/** reasoning-start/delta/end framing for one reasoning (thinking) block — + * mirrors textChunks. Raw LanguageModelV4 part shape verified against + * @ai-sdk/provider typings: { type: 'reasoning-delta', id, delta }. + * Added for the empty-step-recovery suite: a step that THINKS and then says + * nothing must classify as empty (spec 2026-08-21, §4 Part 1). */ +export function reasoningChunks(id: string, text: string) { + return [ + { type: 'reasoning-start', id }, + { type: 'reasoning-delta', id, delta: text }, + { type: 'reasoning-end', id }, + ]; +} + /** One tool-call part. `input` is serialized to a JSON string (the raw shape); * streamText parses it back to an object before the driver sees it. */ export function toolCallChunk(toolCallId: string, toolName: string, input: unknown) { From 43633706fe966b9f69f901f28c57752e05a557f9 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 14:56:32 -0700 Subject: [PATCH 03/12] test(harness): failing specialist-child empty-step tests (spec case 8) Co-Authored-By: Claude Fable 5 --- desktop/tests/harness-session-loop.test.ts | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/desktop/tests/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts index 55e741c5..c1fc126e 100644 --- a/desktop/tests/harness-session-loop.test.ts +++ b/desktop/tests/harness-session-loop.test.ts @@ -1846,4 +1846,38 @@ describe('HarnessSession — empty final step recovery', () => { expect(types(events)).toContain('user-interrupt'); // …but the interrupt won expect(types(events)).not.toContain('turn-complete'); // never 'empty_response' }); + + // Spec case 8 — a specialist child gets the SAME bounded retry. The child + // never-park rule (harness-stall-watchdog.test.ts) is about the watchdog + // leaving send() unsettled; a synchronous capped re-run settles normally. + // `await child.send()` completing IS the settle assertion — a regression to an + // unbounded loop trips this file's test timeout instead of hanging a parent. + it('case 8a: specialist child — empty then content settles send() with end_turn', async () => { + const seen: any[] = []; + const model = scriptedModel([ + stream(finishChunk('stop')), + stream(...textChunks('a', 'report'), finishChunk('stop')), + ], seen); + const child = new HarnessSession(makeOpts({ isSpecialistChild: true }), async () => model as any); + const events = collect(child); + await child.send('go'); + + expect(seen).toHaveLength(2); + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('end_turn'); + expect(events.filter((e) => e.type === 'assistant-text').map((e) => e.data.text)).toEqual(['report']); + }); + + it('case 8b: specialist child — empty twice settles send() with empty_response', async () => { + // scriptedModel REPLAYS its last script when calls outrun it, so this one + // empty script feeds every attempt — the assertion that only TWO calls + // happened is what pins the bound (an unbounded retry would spin here). + const seen: any[] = []; + const model = scriptedModel([stream(finishChunk('stop'))], seen); + const child = new HarnessSession(makeOpts({ isSpecialistChild: true }), async () => model as any); + const events = collect(child); + await child.send('go'); + + expect(seen).toHaveLength(2); + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('empty_response'); + }); }); From 333c20242109860020904c122377b39fb52896f1 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 14:57:24 -0700 Subject: [PATCH 04/12] fix(harness): bounded silent retry for empty final steps; honest 'empty_response' turn end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A step with no text and no tool calls yet an orderly finishReason used to end the turn as a silent end_turn — experienced as ~3min of nothing after a tool result (spec 2026-08-21-empty-final-step-turn-recovery-design.md). Now: one silent re-run (history untouched, both attempts billed), then an honest 'empty_response' stop. finishReason-gated so 'length'/'content-filter' keep today's exact behavior. No new events or IPC; one console.error for diagnosis. Co-Authored-By: Claude Fable 5 --- desktop/src/main/harness/harness-session.ts | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/desktop/src/main/harness/harness-session.ts b/desktop/src/main/harness/harness-session.ts index e574f8b2..c4087691 100644 --- a/desktop/src/main/harness/harness-session.ts +++ b/desktop/src/main/harness/harness-session.ts @@ -1620,6 +1620,10 @@ export class HarnessSession extends EventEmitter { // runs than the conservative 25 — see model-step-budget.ts). const maxSteps = this.opts.harness.limits?.maxSteps ?? stepBudgetFor(this.binding.modelId); let stepsSinceApproval = 0; + // Consecutive contentless steps (empty-step recovery, spec 2026-08-21). + // The single silent retry is allowed only at count 1; any real step resets + // it, so an all-empty turn costs exactly two provider calls. + let consecutiveEmptySteps = 0; let stopReason = 'end_turn'; // Latest step's partial text — the ONLY thing the catch pushes to history // (earlier steps already pushed their assistant + tool messages). Reset per @@ -1712,6 +1716,46 @@ export class HarnessSession extends EventEmitter { this.history.push(this.assistantMessage(step.text, step.toolCalls)); } + // Empty-step recovery (spec: docs/active/specs/2026-08-21-empty-final- + // step-turn-recovery-design.md). A degenerate step — no text, no tool + // calls, yet an orderly finish — used to fall straight into the natural- + // stop break below and end the turn as a silent 'end_turn', which the + // user experiences as the assistant simply never answering (observed + // 3x live, 2026-08-20/21). Re-run it ONCE silently: the push above + // skipped an empty step, so history gained nothing and the re-run sends + // the same conversation (modulo a compaction the loop top may run + // either way — tool-call/result pairing holds through both). + // A SECOND consecutive empty step ends the turn honestly instead. + // Reasoning-only steps count as empty BY DECISION (StepResult carries + // no reasoning; the user-visible outcome is identical to silence). + const isEmptyStep = + !step.interrupted && + step.toolCalls.length === 0 && + (!step.text || step.text.trim().length === 0); + // Gate on the "provider claims an orderly finish" shapes ONLY. An empty + // step that finished 'length' is truncation (a retry would hit the same + // limit and must still report max_tokens); 'content-filter' must keep + // its refusal mapping. Without this gate the retry would mask real + // stop reasons behind 'empty_response'. + const orderlyFinish = step.finishReason === undefined + || ['stop', 'unknown', 'other'].includes(step.finishReason); + if (isEmptyStep && orderlyFinish) { + consecutiveEmptySteps++; + if (consecutiveEmptySteps === 1) { + // One main-process log line so the silent retry is diagnosable — + // deliberately NOT a transcript event (the emit surface is frozen). + console.error(`[harness] empty step (no text, no tool calls, finishReason: ${step.finishReason ?? 'undefined'}) — retrying once`); + continue turnLoop; + } + // Second consecutive empty step: an orderly completion with an honest + // reason. Set HERE, not in mapStopReason — 'empty_response' is a + // loop-level judgment about two steps, not a mapping of one + // provider finishReason. + stopReason = 'empty_response'; + break; + } + consecutiveEmptySteps = 0; // any real step re-arms the single retry + if (step.toolCalls.length === 0) { // Natural stop. finishReason 'length' (truncated output, including a // truncated tool-call) collapses to 'max_tokens' via mapStopReason. From 0a9062570b0c810d10c1ca15079f7c9cc68931dc Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 14:58:05 -0700 Subject: [PATCH 05/12] feat(renderer): footer copy for the new 'empty_response' turn end (spec case 9) Co-Authored-By: Claude Fable 5 --- .../components/AssistantTurnBubble.test.tsx | 20 +++++++++++++++++++ .../components/AssistantTurnBubble.tsx | 8 +++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx index f458d7f6..70bca8d6 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx @@ -383,4 +383,24 @@ describe('AssistantTurnBubble — stop reason footer', () => { }); expect(container.textContent).not.toContain('Question closed'); }); + + it('renders the empty-response copy for stopReason empty_response', () => { + // Empty-step recovery (spec 2026-08-21): the harness already retried once + // silently — this footer is the honest end after a SECOND contentless step. + const { container } = renderTurn({ + turn: turnWithStopReason('empty_response'), + toolGroups: new Map(), + toolCalls: new Map(), + }); + expect(container.textContent).toContain('The model returned an empty response. Retrying may help.'); + }); + + it('end_turn never renders the empty-response copy', () => { + const { container } = renderTurn({ + turn: turnWithStopReason('end_turn'), + toolGroups: new Map(), + toolCalls: new Map(), + }); + expect(container.textContent).not.toContain('empty response'); + }); }); diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index 430a30fb..df03bbc6 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -24,7 +24,7 @@ interface Props { // `tool_use` is filtered upstream at transcript-watcher.ts (it means "awaiting // tool result", not a real completion). `end_turn` — the normal completion — // reaches the reducer but is filtered at the render gate below, because it -// carries no abnormal signal worth surfacing. The four keys below are the +// carries no abnormal signal worth surfacing. The keys below are the // ones that ARE worth surfacing (truncation / refusal / etc.). // Provider-aware: native (local/cloud) sessions must not be labelled "Claude". // The two subject-carrying lines swap in the assistant's display name; the rest @@ -42,6 +42,12 @@ function stopReasonCopy(reason: string, provider: SessionProvider | undefined): // it a dismissed turn is visually identical to a session that silently died, // and the user can't trust either signal. question_dismissed: 'Question closed — waiting for you.', + // Empty-step recovery (spec 2026-08-21): the harness already retried once + // silently; this is the honest end after a SECOND contentless step. + // Deliberately provider-neutral ("The model") — per the error-message + // standards this is general + non-committal, and the failure belongs to + // the model, not the assistant persona. + empty_response: 'The model returned an empty response. Retrying may help.', }; return map[reason] ?? `Response ended: ${reason}.`; } From 7475f6bb2a93a3ee30325c1086791433e2787d8c Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 15:04:37 -0700 Subject: [PATCH 06/12] =?UTF-8?q?test(harness):=20downstream=20suites=20?= =?UTF-8?q?=E2=80=94=20a=20bare=20stop=20now=20takes=20two=20consecutive?= =?UTF-8?q?=20empty=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-step retry changes the harness contract: one scripted {} step no longer ends a turn (it gets silently re-run and consumes the next script). Eval-runner and specialist-nudge tests that staged 'model simply stopped' double their empty step to keep their premise; the specialist test now also pins the layered recovery (step-level retry first, turn-level nudge second). Co-Authored-By: Claude Fable 5 --- desktop/tests/harness-eval-assertions.test.ts | 6 +++++- desktop/tests/harness-review-runner.test.ts | 14 ++++++++++++-- desktop/tests/specialist-run.test.ts | 8 +++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/desktop/tests/harness-eval-assertions.test.ts b/desktop/tests/harness-eval-assertions.test.ts index 6de5ea2c..69c4979c 100644 --- a/desktop/tests/harness-eval-assertions.test.ts +++ b/desktop/tests/harness-eval-assertions.test.ts @@ -654,7 +654,11 @@ describe('against a real runCase transcript', () => { // wrap-up turn's steps rather than a second copy of step 1. const model = scriptModel([ { toolCalls: [{ name: 'Read', input: { file_path: 'README.md' } }] }, - {}, // ends the turn with no review + // TWO empty steps: the harness silently retries a single empty step + // (empty-step recovery, spec 2026-08-21), so ending the testing turn + // with no review now takes a consecutive pair. + {}, + {}, { toolCalls: [{ name: 'Bash', input: { command: 'ls' } }] }, // denied during wrap-up { text: 'Here is my review.' }, ]); diff --git a/desktop/tests/harness-review-runner.test.ts b/desktop/tests/harness-review-runner.test.ts index 500d3d95..b50126e7 100644 --- a/desktop/tests/harness-review-runner.test.ts +++ b/desktop/tests/harness-review-runner.test.ts @@ -952,7 +952,10 @@ describe('what the model actually receives (2026-08-11 amnesia bug)', () => { chunks: [toolCallChunk('c1', 'Read', { file_path: 'README.md' }), finishChunk('tool-calls')], }) }; } - if (call === 2) return { stream: simulateReadableStream({ chunks: [finishChunk('stop')] }) }; + // Calls 2 AND 3 are empty: the harness silently retries a single empty + // step (empty-step recovery, spec 2026-08-21), so "a bare stop" now + // takes a consecutive pair to actually end the testing turn. + if (call === 2 || call === 3) return { stream: simulateReadableStream({ chunks: [finishChunk('stop')] }) }; return { stream: simulateReadableStream({ chunks: [...textChunks('t', 'The review.'), finishChunk('stop')], }) }; @@ -1110,6 +1113,10 @@ describe('runCase salvage', () => { // testing turn. It has to be here: the driver keeps looping inside one // send() until the model stops calling tools, so a text step here would // simply finish the first turn and there would be nothing to rescue. + // TWO of them, because the harness silently retries the first (empty-step + // recovery, spec 2026-08-21) — a single {} would hand the retry the + // review script and finish the testing turn WITH a review. + {}, {}, // Reached only by the SECOND send() the trigger issues. { text: 'Asked, so here is my review.' }, @@ -1148,7 +1155,10 @@ describe('runCase salvage', () => { // decline to ask, missing the exact case the trigger exists for. const model = scriptModel([ { text: 'Now let me check the config…', toolCalls: [{ name: 'Read', input: { file_path: 'a.toml' } }] }, - {}, // testing turn stops here, having emitted narration but no review + // Doubled: the harness retries one empty step (empty-step recovery, + // spec 2026-08-21); the pair ends the testing turn narration-only. + {}, + {}, { text: 'Asked anyway.' }, ]); const run = await runCase({ diff --git a/desktop/tests/specialist-run.test.ts b/desktop/tests/specialist-run.test.ts index 69fd73eb..615b83e6 100644 --- a/desktop/tests/specialist-run.test.ts +++ b/desktop/tests/specialist-run.test.ts @@ -270,7 +270,13 @@ describe('specialist foreground run (Task 7)', () => { it('nudges EXACTLY once when the child ends with no report, and accepts the second answer', async () => { await withParent([ - stream(finishChunk('stop')), // turn 1: no text at all + // Turn 1 must end with no report DESPITE the harness's own empty-step + // retry (spec 2026-08-21) — so it takes TWO consecutive empty streams + // (attempt + silent retry → the turn ends 'empty_response'). Only then + // does the delegation layer's nudge fire. This also pins the layered + // recovery: step-level retry first, turn-level nudge second. + stream(finishChunk('stop')), // turn 1, attempt 1: empty + stream(finishChunk('stop')), // turn 1, silent retry: still empty stream(...textChunks('t', 'REPORT: after the nudge'), finishChunk('stop')), // turn 2: the real report ]); From 581c59989e6332d14219960a188b20217a3bbaba Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 15:04:37 -0700 Subject: [PATCH 07/12] feat(renderer): render the empty_response footer even when the turn streamed nothing (spec decision 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review-driven addition: content-creating actions are what mint assistant turns, so a fully-contentless empty_response turn had nothing to attach its honest footer to — the worst-case shape of the bug stayed silent. Reducer now creates the turn on abnormal-stopReason turn-complete; the bubble renders a footer-only row for it. end_turn/interrupt behavior unchanged. Co-Authored-By: Claude Fable 5 --- .../components/AssistantTurnBubble.test.tsx | 23 ++++++++++ .../components/AssistantTurnBubble.tsx | 20 +++++++++ .../state/__tests__/chat-reducer.test.ts | 45 +++++++++++++++++++ desktop/src/renderer/state/chat-reducer.ts | 25 ++++++++++- 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx index 70bca8d6..6d8be502 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx @@ -403,4 +403,27 @@ describe('AssistantTurnBubble — stop reason footer', () => { }); expect(container.textContent).not.toContain('empty response'); }); + + it('renders a footer-only row for an empty_response turn with no segments', () => { + // Spec 2026-08-21 decision 4: a fully-contentless turn has zero bubbles, so + // the per-bubble footer never fires — but this exact shape is the bug's + // worst case and must still explain itself. + const { container } = renderTurn({ + turn: { ...turnWithStopReason('empty_response'), segments: [] }, + toolGroups: new Map(), + toolCalls: new Map(), + }); + expect(container.textContent).toContain('The model returned an empty response. Retrying may help.'); + }); + + it('renders nothing for a segment-less turn with a normal stopReason', () => { + // Pins the inert path: zero-bubble turns without an abnormal reason keep + // rendering nothing (no ghost rows on replay edge cases). + const { container } = renderTurn({ + turn: { ...turnWithStopReason('end_turn'), segments: [] }, + toolGroups: new Map(), + toolCalls: new Map(), + }); + expect(container.textContent).toBe(''); + }); }); diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index df03bbc6..fec9be8f 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -375,6 +375,26 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC [turn, toolGroups, toolCalls], ); + // Empty-step recovery (spec 2026-08-21, decision 4): a fully-contentless + // turn has ZERO bubbles, so the per-bubble stopReason footer below can never + // fire — yet an abnormal stopReason on such a turn is exactly the signal + // that must not be lost (an 'empty_response' turn with no bubbles IS the + // bug's worst case). Render a footer-only row for it. Zero-bubble turns + // with a normal/absent stopReason keep rendering nothing, byte-for-byte. + // Deliberately NOT wrapped in the assistant-bubble shell: there is no + // message here, and an empty bubble would imply one. + if (bubbles.length === 0) { + if (!turn.stopReason || turn.stopReason === 'end_turn') return null; + return ( +
+
+ {showTurnMetadata && } + +
+
+ ); + } + return ( <> {bubbles.map((bubble, i) => { diff --git a/desktop/src/renderer/state/__tests__/chat-reducer.test.ts b/desktop/src/renderer/state/__tests__/chat-reducer.test.ts index 30a82135..912f2c7c 100644 --- a/desktop/src/renderer/state/__tests__/chat-reducer.test.ts +++ b/desktop/src/renderer/state/__tests__/chat-reducer.test.ts @@ -1047,3 +1047,48 @@ describe('chatReducer NATIVE_TOOL_PREPARING', () => { expect(session.toolCalls.get('real-1')!.status).toBe('failed'); }); }); + +// Empty-step recovery, spec 2026-08-21 decision 4: assistant turns are minted +// by CONTENT actions, so a turn whose every step was contentless had no entry +// to carry its honest stopReason — the footer had nothing to render on and the +// worst-case empty_response turn stayed visually silent. +describe('TRANSCRIPT_TURN_COMPLETE — fully-silent turn (empty-step recovery)', () => { + function initState(sessionId = 'sess-1'): ChatState { + return new Map([[sessionId, createSessionChatState()]]); + } + + it('creates a footer-only turn when turn-complete carries an abnormal stopReason and no content streamed', () => { + let state = initState(); + state = chatReducer(state, { + type: 'TRANSCRIPT_TURN_COMPLETE', sessionId: 'sess-1', uuid: 'u-1', timestamp: 1000, + stopReason: 'empty_response', model: 'stealth/ox-alpha', anthropicRequestId: null, + usage: { inputTokens: 21, outputTokens: 5 }, + } as any); + + const session = state.get('sess-1')!; + expect(session.assistantTurns.size).toBe(1); + const turn = [...session.assistantTurns.values()][0]; + expect(turn.segments).toEqual([]); + expect(turn.stopReason).toBe('empty_response'); + expect(turn.model).toBe('stealth/ox-alpha'); + expect(turn.usage).toMatchObject({ inputTokens: 21, outputTokens: 5 }); + // The turn is on the timeline (it must render), and the turn still ENDED. + expect(session.timeline.some((e: any) => e.kind === 'assistant-turn' && e.turnId === turn.id)).toBe(true); + expect(session.currentTurnId).toBeNull(); + expect(session.isThinking).toBe(false); + }); + + it('end_turn with no content still creates no turn', () => { + // Pins today's skip: normal CC/native completions with no streamed content + // must not grow ghost turns on the timeline. + let state = initState(); + state = chatReducer(state, { + type: 'TRANSCRIPT_TURN_COMPLETE', sessionId: 'sess-1', uuid: 'u-1', timestamp: 1000, + stopReason: 'end_turn', model: null, anthropicRequestId: null, usage: null, + } as any); + + const session = state.get('sess-1')!; + expect(session.assistantTurns.size).toBe(0); + expect(session.timeline).toEqual([]); + }); +}); diff --git a/desktop/src/renderer/state/chat-reducer.ts b/desktop/src/renderer/state/chat-reducer.ts index 91d77a51..5b6c0acf 100644 --- a/desktop/src/renderer/state/chat-reducer.ts +++ b/desktop/src/renderer/state/chat-reducer.ts @@ -1407,7 +1407,8 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { // if it's already null (edge case: turn-complete arrived before any // assistant text), skip metadata attachment but still call endTurn. const completingTurnId = session.currentTurnId; - const assistantTurns = new Map(session.assistantTurns); + let assistantTurns = new Map(session.assistantTurns); + let timeline = session.timeline; if (completingTurnId) { const turn = assistantTurns.get(completingTurnId); if (turn) { @@ -1422,9 +1423,29 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { usage: action.usage, }); } + } else if (action.stopReason && action.stopReason !== 'end_turn') { + // Empty-step recovery (spec 2026-08-21, decision 4): assistant turns + // are minted by CONTENT actions, so a turn whose every step was + // contentless has no entry to carry its honest stopReason — the + // worst-case shape of the empty_response bug would still render as + // unexplained silence. Create the (segment-less) turn here so the + // footer has something to attach to. Normal completions keep the + // long-standing skip above: an end_turn with no content carries no + // signal worth a timeline row. + const created = getOrCreateTurn(session); + assistantTurns = created.assistantTurns; + timeline = created.timeline; + const turn = assistantTurns.get(created.currentTurnId)!; + assistantTurns.set(created.currentTurnId, { + ...turn, + stopReason: action.stopReason, + model: action.model, + anthropicRequestId: action.anthropicRequestId, + usage: action.usage, + }); } - next.set(action.sessionId, { ...session, ...endTurn(session, undefined, assistantTurns) }); + next.set(action.sessionId, { ...session, timeline, ...endTurn(session, undefined, assistantTurns) }); return next; } From 3270a00a448198f8875bb3040e409d857605c77d Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 22:20:54 -0700 Subject: [PATCH 08/12] =?UTF-8?q?fix(harness):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20one=20emptiness=20predicate,=20orderly-finish=20lis?= =?UTF-8?q?t=20beside=20mapStopReason=20(+tool-calls),=20structured=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on the retry ladder: - Whitespace-only steps ('\n\n') were pushed to history (truthiness gate) yet classified empty (trim gate) — the retry then re-sent a conversation ending in a dangling whitespace assistant message. One shared predicate now feeds both gates. - The orderly-finish list lived 1,480 lines from mapStopReason (drift risk) and excluded 'tool-calls' — the empty shape a stream takes when every announced call is dropped as malformed, likeliest on small local models; it previously ended the turn with the raw passthrough reason 'tool-calls'. Now ORDERLY_EMPTY_FINISHES, defined next to mapStopReason. - The retry's only diagnostic was console.error, which reaches nobody in a packaged build; now the structured log() that writes ~/.claude/desktop.log. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL --- desktop/src/main/harness/harness-session.ts | 58 +++++++++++++++------ desktop/tests/harness-session-loop.test.ts | 40 ++++++++++++++ 2 files changed, 83 insertions(+), 15 deletions(-) diff --git a/desktop/src/main/harness/harness-session.ts b/desktop/src/main/harness/harness-session.ts index c4087691..9aedf6f4 100644 --- a/desktop/src/main/harness/harness-session.ts +++ b/desktop/src/main/harness/harness-session.ts @@ -265,6 +265,19 @@ function mapStopReason(finishReason: string | undefined): string { } } +// The finishReason shapes that mean "the provider claims an orderly finish" — +// the only shapes eligible for the empty-step retry (spec 2026-08-21). Kept +// HERE, next to mapStopReason, so the two finishReason vocabularies stay one +// list: a reason added to the switch above must be classified here too, or +// empty steps with that reason silently lose the retry. +// 'tool-calls' is deliberately included: with ZERO parsed calls it means the +// stream announced tool use but every call was dropped as malformed/truncated +// (fragments are discarded at the tool-input part handlers) — nothing ran, +// nothing rendered, so it is the same degenerate shape as a bare 'stop' and a +// retry is exactly right. 'length' (truncation) and 'content-filter' (refusal) +// stay excluded so their honest mappings are never masked by a retry. +const ORDERLY_EMPTY_FINISHES = new Set(['stop', 'unknown', 'other', 'tool-calls']); + /** Widening advice per tool, in that tool's OWN vocabulary. * * WHY (2026-08-06, Task 18): this path appended "Re-run with offset/limit, or @@ -1710,9 +1723,19 @@ export class HarnessSession extends EventEmitter { return; } + // ONE emptiness predicate for BOTH the history push and the retry gate + // below. Whitespace-only text counts as empty for both: if the push + // used truthiness while the retry used trim(), a '\n\n' step would be + // pushed to history AND retried — the re-run's request would end in a + // dangling whitespace assistant message (which Anthropic-shaped + // endpoints reject with a 400), breaking the "history gained nothing" + // invariant the retry rests on. + const stepHasText = !!step.text && step.text.trim().length > 0; + // Record the assistant message (text + any tool-call parts). Skip an - // empty one (no text and no calls) so we never push a content-less turn. - if (step.text || step.toolCalls.length > 0) { + // empty one (no real text and no calls) so we never push a content-less + // turn. + if (stepHasText || step.toolCalls.length > 0) { this.history.push(this.assistantMessage(step.text, step.toolCalls)); } @@ -1722,29 +1745,34 @@ export class HarnessSession extends EventEmitter { // stop break below and end the turn as a silent 'end_turn', which the // user experiences as the assistant simply never answering (observed // 3x live, 2026-08-20/21). Re-run it ONCE silently: the push above - // skipped an empty step, so history gained nothing and the re-run sends - // the same conversation (modulo a compaction the loop top may run - // either way — tool-call/result pairing holds through both). + // skipped an empty step, so HISTORY gained nothing and the re-run sends + // the same conversation — modulo the loop top, which may drain pending + // steers and run compaction first (deliberate: a steer posted during + // the dead step should reach the retry, and it stays in history for the + // next turn either way; tool-call/result pairing holds throughout). // A SECOND consecutive empty step ends the turn honestly instead. // Reasoning-only steps count as empty BY DECISION (StepResult carries // no reasoning; the user-visible outcome is identical to silence). const isEmptyStep = !step.interrupted && step.toolCalls.length === 0 && - (!step.text || step.text.trim().length === 0); - // Gate on the "provider claims an orderly finish" shapes ONLY. An empty - // step that finished 'length' is truncation (a retry would hit the same - // limit and must still report max_tokens); 'content-filter' must keep - // its refusal mapping. Without this gate the retry would mask real - // stop reasons behind 'empty_response'. + !stepHasText; + // Gate on the "provider claims an orderly finish" shapes ONLY (single + // list next to mapStopReason — see ORDERLY_EMPTY_FINISHES for why + // 'tool-calls' is in and 'length'/'content-filter' are out). Without + // this gate the retry would mask real stop reasons behind + // 'empty_response'. const orderlyFinish = step.finishReason === undefined - || ['stop', 'unknown', 'other'].includes(step.finishReason); + || ORDERLY_EMPTY_FINISHES.has(step.finishReason); if (isEmptyStep && orderlyFinish) { consecutiveEmptySteps++; if (consecutiveEmptySteps === 1) { - // One main-process log line so the silent retry is diagnosable — - // deliberately NOT a transcript event (the emit surface is frozen). - console.error(`[harness] empty step (no text, no tool calls, finishReason: ${step.finishReason ?? 'undefined'}) — retrying once`); + // One structured log line so the silent retry is diagnosable from + // ~/.claude/desktop.log (console.error reaches nobody in a packaged + // build) — deliberately NOT a transcript event (emit surface frozen). + log('WARN', 'HarnessSession', 'empty step (no text, no tool calls) — retrying once', { + sessionId: this.opts.sessionId, finishReason: step.finishReason ?? 'undefined', + }); continue turnLoop; } // Second consecutive empty step: an orderly completion with an honest diff --git a/desktop/tests/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts index c1fc126e..2d4ef66a 100644 --- a/desktop/tests/harness-session-loop.test.ts +++ b/desktop/tests/harness-session-loop.test.ts @@ -1880,4 +1880,44 @@ describe('HarnessSession — empty final step recovery', () => { expect(seen).toHaveLength(2); expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('empty_response'); }); + + it('whitespace-only step: classified empty AND kept out of history (review fix)', async () => { + // The history push and the retry gate MUST share one emptiness predicate. + // If the push used truthiness ('\n\n' is truthy) while the retry used + // trim(), the whitespace step would be pushed to history AND retried — the + // re-run's request would end in a dangling whitespace assistant message, + // which Anthropic-shaped endpoints reject with a 400. + const seen: any[] = []; + const model = scriptedModel([ + stream(...textChunks('a', '\n \n'), finishChunk('stop')), // whitespace-only step + stream(...textChunks('b', 'recovered'), finishChunk('stop')), + ], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); // retried like a fully-silent step + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('end_turn'); + // History: user + ONLY the real answer — the whitespace step pushed nothing. + const history = (session as any).history as any[]; + expect(history.map((m) => m.role)).toEqual(['user', 'assistant']); + expect(JSON.stringify(history[1])).toContain('recovered'); + }); + + it("finishReason 'tool-calls' with ZERO parsed calls: orderly → retried (review fix)", async () => { + // A stream that announces tool use but whose every call was dropped as + // malformed/truncated leaves toolCalls empty with finishReason + // 'tool-calls' — the likeliest empty-step shape on small local models. + // Excluding it from ORDERLY_EMPTY_FINISHES ended the turn with the raw + // passthrough stopReason 'tool-calls' (meaningless to the user) instead of + // the retry ladder. + const seen: any[] = []; + const model = scriptedModel([stream(finishChunk('tool-calls'))], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); // one retry, then the honest end + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('empty_response'); + }); }); From e3c645327d6e815a41200c35d1ce32f427f58000 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 22:21:11 -0700 Subject: [PATCH 09/12] fix(renderer): turn-complete's segment-less mint is idempotent by uuid; one stamp site; event-time timestamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings: the mint branch broke TRANSCRIPT_TURN_COMPLETE's absorb contract — the watcher re-emits turn-complete and re-dock replay re-delivers it, while content actions ARE uuid-deduped, so every replay arrived with currentTurnId null and appended a fresh ghost turn + timeline row, unbounded. Abnormal completions now record their uuid in seenUuids (both the stamp and the mint path — a live max_tokens turn must not re-mint as a ghost on replay) and the mint checks it. The duplicated metadata-stamp literal (which had already diverged: 'model: action.model' vs '?? turn.model') is collapsed to one stamp site, and a minted turn takes the EVENT's timestamp instead of Date.now() so a replayed footer row doesn't display the re-dock time. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL --- .../state/__tests__/chat-reducer.test.ts | 64 +++++++++++++++++ desktop/src/renderer/state/chat-reducer.ts | 71 ++++++++++++------- 2 files changed, 108 insertions(+), 27 deletions(-) diff --git a/desktop/src/renderer/state/__tests__/chat-reducer.test.ts b/desktop/src/renderer/state/__tests__/chat-reducer.test.ts index 912f2c7c..697279f6 100644 --- a/desktop/src/renderer/state/__tests__/chat-reducer.test.ts +++ b/desktop/src/renderer/state/__tests__/chat-reducer.test.ts @@ -1091,4 +1091,68 @@ describe('TRANSCRIPT_TURN_COMPLETE — fully-silent turn (empty-step recovery)', expect(session.assistantTurns.size).toBe(0); expect(session.timeline).toEqual([]); }); + + it('the mint is IDEMPOTENT by uuid: a replayed turn-complete never appends a second ghost turn', () => { + // The watcher's re-emit contract and re-dock replay both re-deliver + // turn-complete relying on the reducer absorbing duplicates (readNewLines: + // "the reducer absorbs them"). Content actions are uuid-deduped on replay, + // so the replayed turn-complete arrives with currentTurnId null — without + // a uuid guard EVERY replay appended a fresh ghost turn + timeline row. + const complete = { + type: 'TRANSCRIPT_TURN_COMPLETE', sessionId: 'sess-1', uuid: 'u-1', timestamp: 1000, + stopReason: 'empty_response', model: null, anthropicRequestId: null, + usage: { inputTokens: 21, outputTokens: 5 }, + } as any; + let state = initState(); + state = chatReducer(state, complete); + state = chatReducer(state, complete); // replay of the same event + state = chatReducer(state, complete); // and again + + const session = state.get('sess-1')!; + expect(session.assistantTurns.size).toBe(1); + expect(session.timeline.filter((e: any) => e.kind === 'assistant-turn')).toHaveLength(1); + }); + + it('a replayed abnormal turn-complete for a turn that HAD content re-mints nothing', () => { + // Live pass: text creates the turn, turn-complete 'max_tokens' stamps it. + // Replay into EXISTING state: the text action is uuid-deduped (no turn is + // re-created, currentTurnId stays null), then the same turn-complete + // arrives again — it must be absorbed, not minted as a segment-less ghost + // beside the real turn. + let state = initState(); + state = chatReducer(state, { + type: 'TRANSCRIPT_ASSISTANT_TEXT', sessionId: 'sess-1', uuid: 'text-1', + timestamp: 900, text: 'partial answer', + } as any); + const complete = { + type: 'TRANSCRIPT_TURN_COMPLETE', sessionId: 'sess-1', uuid: 'u-1', timestamp: 1000, + stopReason: 'max_tokens', model: null, anthropicRequestId: null, usage: null, + } as any; + state = chatReducer(state, complete); + // Re-dock replay: text drops via seenUuids, turn-complete re-delivers. + state = chatReducer(state, { + type: 'TRANSCRIPT_ASSISTANT_TEXT', sessionId: 'sess-1', uuid: 'text-1', + timestamp: 900, text: 'partial answer', + } as any); + state = chatReducer(state, complete); + + const session = state.get('sess-1')!; + expect(session.assistantTurns.size).toBe(1); // the real turn only — no ghost + expect(session.timeline.filter((e: any) => e.kind === 'assistant-turn')).toHaveLength(1); + expect([...session.assistantTurns.values()][0].stopReason).toBe('max_tokens'); + }); + + it('a minted turn is stamped with the EVENT timestamp, not the replay-time clock', () => { + // getOrCreateTurn defaults to Date.now(); on a re-dock replay that is the + // dock time, which the footer row's timestamp would then display. The mint + // overrides it with the event's own timestamp. + let state = initState(); + state = chatReducer(state, { + type: 'TRANSCRIPT_TURN_COMPLETE', sessionId: 'sess-1', uuid: 'u-1', timestamp: 12345, + stopReason: 'empty_response', model: null, anthropicRequestId: null, usage: null, + } as any); + + const turn = [...state.get('sess-1')!.assistantTurns.values()][0]; + expect(turn.timestamp).toBe(12345); + }); }); diff --git a/desktop/src/renderer/state/chat-reducer.ts b/desktop/src/renderer/state/chat-reducer.ts index 5b6c0acf..bdc2bc13 100644 --- a/desktop/src/renderer/state/chat-reducer.ts +++ b/desktop/src/renderer/state/chat-reducer.ts @@ -1404,15 +1404,51 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { // Attach completion metadata to the completing turn before clearing // turn-scoped state via endTurn(). currentTurnId is the in-flight turn; - // if it's already null (edge case: turn-complete arrived before any - // assistant text), skip metadata attachment but still call endTurn. - const completingTurnId = session.currentTurnId; + // if it's already null (turn-complete arrived before any assistant + // content), an ABNORMAL stopReason mints a segment-less turn to carry + // it — see below. Resolve WHICH turn gets stamped first, then stamp + // once, so the mint path can never drift from the normal path's field + // policy (it did, briefly: `model: action.model` vs `?? turn.model`). + const abnormalStop = !!action.stopReason && action.stopReason !== 'end_turn'; let assistantTurns = new Map(session.assistantTurns); let timeline = session.timeline; - if (completingTurnId) { - const turn = assistantTurns.get(completingTurnId); + let seenUuids = session.seenUuids; + let targetTurnId = session.currentTurnId; + let mintedTimestamp: number | null = null; + if (!targetTurnId && abnormalStop && !session.seenUuids.has(action.uuid)) { + // Empty-step recovery (spec 2026-08-21, decision 4): assistant turns + // are minted by CONTENT actions, so a turn whose every step was + // contentless has no entry to carry its honest stopReason — the + // worst-case shape of the empty_response bug would still render as + // unexplained silence. Create the (segment-less) turn here so the + // footer has something to attach to. Normal completions keep the + // long-standing skip: an end_turn with no content carries no signal + // worth a timeline row. + // The seenUuids guard keeps this branch IDEMPOTENT: the watcher's + // re-emit contract and re-dock replay both re-deliver turn-complete + // (readNewLines: "the reducer absorbs them"), and content actions are + // uuid-deduped on replay so currentTurnId stays null — without the + // guard every replay would append a fresh ghost turn + timeline row. + const created = getOrCreateTurn(session); + assistantTurns = created.assistantTurns; + timeline = created.timeline; + targetTurnId = created.currentTurnId; + // Replay delivers the original event: stamp the turn with the event's + // own time, not Date.now() (which would show the re-dock time). + mintedTimestamp = action.timestamp; + } + if (abnormalStop) { + // Recorded for BOTH the stamp and the mint path: a live abnormal + // completion stamped onto a content turn must not re-mint as a ghost + // when the same event replays into existing state (content actions get + // deduped, so the replayed turn-complete arrives with currentTurnId + // null). Normal end_turn completions never grow the set. + seenUuids = new Set(session.seenUuids).add(action.uuid); + } + if (targetTurnId) { + const turn = assistantTurns.get(targetTurnId); if (turn) { - assistantTurns.set(completingTurnId, { + assistantTurns.set(targetTurnId, { ...turn, stopReason: action.stopReason, // Preserve any model already captured on the turn (e.g. from @@ -1421,31 +1457,12 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState { model: action.model ?? turn.model, anthropicRequestId: action.anthropicRequestId, usage: action.usage, + ...(mintedTimestamp !== null ? { timestamp: mintedTimestamp } : {}), }); } - } else if (action.stopReason && action.stopReason !== 'end_turn') { - // Empty-step recovery (spec 2026-08-21, decision 4): assistant turns - // are minted by CONTENT actions, so a turn whose every step was - // contentless has no entry to carry its honest stopReason — the - // worst-case shape of the empty_response bug would still render as - // unexplained silence. Create the (segment-less) turn here so the - // footer has something to attach to. Normal completions keep the - // long-standing skip above: an end_turn with no content carries no - // signal worth a timeline row. - const created = getOrCreateTurn(session); - assistantTurns = created.assistantTurns; - timeline = created.timeline; - const turn = assistantTurns.get(created.currentTurnId)!; - assistantTurns.set(created.currentTurnId, { - ...turn, - stopReason: action.stopReason, - model: action.model, - anthropicRequestId: action.anthropicRequestId, - usage: action.usage, - }); } - next.set(action.sessionId, { ...session, timeline, ...endTurn(session, undefined, assistantTurns) }); + next.set(action.sessionId, { ...session, timeline, seenUuids, ...endTurn(session, undefined, assistantTurns) }); return next; } From a04a30f2435a058e57dd7d37f3156671f0578593 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 22:21:11 -0700 Subject: [PATCH 10/12] =?UTF-8?q?fix(renderer):=20the=20empty=5Fresponse?= =?UTF-8?q?=20footer=20was=20dead=20code=20=E2=80=94=20open=20the=20ChatVi?= =?UTF-8?q?ew/BubbleFeed=20gates=20for=20abnormal=20segment-less=20turns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR's user-visible fix never rendered: both timeline render sites drop segment-less turns before AssistantTurnBubble mounts, and every shipped test mounted the bubble directly — so all tests passed while the real app still showed the exact unexplained silence the PR exists to fix. One shared abnormalStopReason() predicate (exported from AssistantTurnBubble) now drives the bubble's two footer gates AND both timeline gates; a new test crosses the ChatView boundary (state in → footer out) so this can't silently regress. The footer-only row also gains the timestamp trailer the bubble path already had, and the copy states the verified fact: 'The model returned an empty response twice. Retrying may help.' Visible side effect (flagged for Destin): a turn interrupted while still 'preparing' a tool call — segment-less after preparing-card reaping — now renders its 'Interrupted.' footer instead of vanishing entirely. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL --- .../components/AssistantTurnBubble.test.tsx | 22 +++- .../components/AssistantTurnBubble.tsx | 36 ++++-- desktop/src/renderer/components/ChatView.tsx | 9 +- .../renderer/components/buddy/BubbleFeed.tsx | 7 +- .../chatview-empty-response-gate.test.tsx | 106 ++++++++++++++++++ 5 files changed, 167 insertions(+), 13 deletions(-) create mode 100644 desktop/tests/chatview-empty-response-gate.test.tsx diff --git a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx index 6d8be502..4173d2e1 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx @@ -392,7 +392,7 @@ describe('AssistantTurnBubble — stop reason footer', () => { toolGroups: new Map(), toolCalls: new Map(), }); - expect(container.textContent).toContain('The model returned an empty response. Retrying may help.'); + expect(container.textContent).toContain('The model returned an empty response twice. Retrying may help.'); }); it('end_turn never renders the empty-response copy', () => { @@ -413,7 +413,25 @@ describe('AssistantTurnBubble — stop reason footer', () => { toolGroups: new Map(), toolCalls: new Map(), }); - expect(container.textContent).toContain('The model returned an empty response. Retrying may help.'); + expect(container.textContent).toContain('The model returned an empty response twice. Retrying may help.'); + }); + + it('the footer-only row carries the timestamp when showTimestamps is on', () => { + // The bubble path renders a timestamp on its last bubble; the footer-only + // row must not silently drop that trailer member — "when did it go + // silent?" is the first question an empty_response row raises. + const { container } = render( + + + + ); + expect(container.querySelector('.bubble-timestamp')).not.toBeNull(); }); it('renders nothing for a segment-less turn with a normal stopReason', () => { diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index fec9be8f..e8ad7f27 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -43,15 +43,29 @@ function stopReasonCopy(reason: string, provider: SessionProvider | undefined): // and the user can't trust either signal. question_dismissed: 'Question closed — waiting for you.', // Empty-step recovery (spec 2026-08-21): the harness already retried once - // silently; this is the honest end after a SECOND contentless step. - // Deliberately provider-neutral ("The model") — per the error-message - // standards this is general + non-committal, and the failure belongs to + // silently; this is the honest end after a SECOND contentless step — + // "twice" states that verified fact (error-message standards: specific + // and accurate). "Retrying may help" stays: it refers to a LATER manual + // nudge, which recovered all three observed live incidents — distinct + // from the immediate auto-retry that just failed. + // Deliberately provider-neutral ("The model") — the failure belongs to // the model, not the assistant persona. - empty_response: 'The model returned an empty response. Retrying may help.', + empty_response: 'The model returned an empty response twice. Retrying may help.', }; return map[reason] ?? `Response ended: ${reason}.`; } +// The single definition of "a stopReason worth surfacing" — `end_turn` is the +// normal completion and carries no signal. Shared by this component's two +// footer gates AND the timeline gates in ChatView / buddy BubbleFeed, which +// must let a segment-less turn through exactly when this returns true (a +// segment-less turn that renders nothing here would be dropped-then-mounted +// for no reason; one that renders a footer must NOT be dropped upstream — +// that exact mismatch shipped the empty_response footer as dead code once). +export function abnormalStopReason(reason: string | null | undefined): boolean { + return !!reason && reason !== 'end_turn'; +} + // Collapsible disclosure for the model's reasoning / chain of thought. // Collapsed by default — user explicitly chose this UX so reasoning doesn't // dominate the chat view. Expanding reveals the full markdown body. @@ -384,12 +398,20 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC // Deliberately NOT wrapped in the assistant-bubble shell: there is no // message here, and an empty bubble would imply one. if (bubbles.length === 0) { - if (!turn.stopReason || turn.stopReason === 'end_turn') return null; + if (!abnormalStopReason(turn.stopReason)) return null; return (
{showTurnMetadata && } - + + {/* Same trailer members as the bubble path below — the timestamp + matters MOST here: "when did it go silent?" is the first question + an empty_response row raises. */} + {showTimestamps && turn.timestamp && ( +
+ {formatBubbleTime(turn.timestamp)} +
+ )}
); @@ -453,7 +475,7 @@ export default React.memo(function AssistantTurnBubble({ turn, toolGroups, toolC {/* Render stopReason explainer only once per turn — on the last bubble. Gate out `end_turn` (normal completion) — it reaches the reducer but carries no abnormal signal worth surfacing to the user. */} - {isLastBubble && turn.stopReason && turn.stopReason !== 'end_turn' && } + {isLastBubble && abnormalStopReason(turn.stopReason) && } {showTimestamps && isLastBubble && turn.timestamp && (
{formatBubbleTime(turn.timestamp)} diff --git a/desktop/src/renderer/components/ChatView.tsx b/desktop/src/renderer/components/ChatView.tsx index 0a8accf1..dcfd9dd7 100644 --- a/desktop/src/renderer/components/ChatView.tsx +++ b/desktop/src/renderer/components/ChatView.tsx @@ -4,7 +4,7 @@ import { HISTORY_EXPAND_PROMPT_ID } from '../state/chat-types'; import UserMessage from './UserMessage'; import SpecialistReportCard from './SpecialistReportCard'; import QueuedMessagesStrip from './QueuedMessagesStrip'; -import AssistantTurnBubble from './AssistantTurnBubble'; +import AssistantTurnBubble, { abnormalStopReason } from './AssistantTurnBubble'; import ToolCard from './ToolCard'; import PromptCard, { PromptCardButton } from './PromptCard'; import { sendPromptInput } from '../state/prompt-input'; @@ -790,7 +790,12 @@ export default function ChatView({ sessionId, visible, sessionActive, resumeInfo break; case 'assistant-turn': { const turn = state.assistantTurns.get(entry.turnId); - if (!turn || turn.segments.length === 0) return null; + // A segment-less turn normally renders nothing — EXCEPT when + // it carries an abnormal stopReason, whose footer row is the + // whole fix for the empty_response bug (a fully-contentless + // turn must not end in unexplained silence). Same predicate + // as AssistantTurnBubble's own zero-bubble gate. + if (!turn || (turn.segments.length === 0 && !abnormalStopReason(turn.stopReason))) return null; key = entry.turnId; content = ( ({ state: {} as any })); + +vi.mock('../src/renderer/state/chat-context', () => ({ + useChatState: () => mocks.state, + useChatDispatch: () => vi.fn(), +})); + +vi.mock('../src/renderer/state/ArtifactContext', () => ({ + useArtifact: () => ({ + state: { drawerOpenBySession: {}, drawerExpanded: false }, + dispatch: vi.fn(), + }), +})); + +if (typeof (globalThis as any).IntersectionObserver === 'undefined') { + (globalThis as any).IntersectionObserver = class { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { return []; } + }; +} + +import ChatView from '../src/renderer/components/ChatView'; + +/** A segment-less assistant turn, as minted by TRANSCRIPT_TURN_COMPLETE's + * abnormal-stopReason branch (chat-reducer.ts). */ +function segmentlessTurn(stopReason: string | null) { + return { + id: 'turn_1', + segments: [] as any[], + timestamp: 1000, + stopReason, + model: null, + usage: null, + anthropicRequestId: null, + }; +} + +function renderWithTurn(stopReason: string | null) { + mocks.state = { + ...emptySessionState(), + timeline: [{ kind: 'assistant-turn', turnId: 'turn_1' }], + assistantTurns: new Map([['turn_1', segmentlessTurn(stopReason)]]), + }; + return render(); +} + +describe('ChatView timeline gate — segment-less turns (empty-step recovery)', () => { + it('renders the empty_response footer for a segment-less turn (end-to-end through the gate)', () => { + const { container } = renderWithTurn('empty_response'); + expect(container.textContent).toContain('The model returned an empty response twice. Retrying may help.'); + }); + + it('still drops a segment-less turn that completed normally', () => { + cleanup(); + const { container } = renderWithTurn('end_turn'); + expect(container.textContent).not.toContain('empty response'); + }); + + it('still drops a segment-less turn with no stopReason at all', () => { + cleanup(); + const { container } = renderWithTurn(null); + expect(container.textContent).not.toContain('Response ended'); + }); +}); From cb34650adb407d91e707ab8d5d2eb17d2a8a574d Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 22:21:11 -0700 Subject: [PATCH 11/12] docs(chat-reducer): stopReason is an open set incl. empty_response; segment-less turns exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The doc enumerated stopReason as a closed set and documented turns as minted only by content actions — the invariant the empty-step-recovery PR inverts. Records the new mint rule, its uuid idempotency, and the shared abnormalStopReason() gate predicate. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL --- docs/chat-reducer.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/chat-reducer.md b/docs/chat-reducer.md index e0d5e45b..0ed19ccd 100644 --- a/docs/chat-reducer.md +++ b/docs/chat-reducer.md @@ -67,7 +67,9 @@ Guard: `chat-reducer.test.ts` → "chatReducer tool card duplication". Historica `AssistantTurn` carries four fields populated from the JSONL transcript: -- `stopReason: string | null` — set only for non-`end_turn` completions (`max_tokens`, `refusal`, `stop_sequence`, `pause_turn`). Rendered inline as a footer under the affected turn; `null` means the turn completed normally. The transcript-watcher filters `tool_use` upstream; `end_turn` reaches the reducer but is filtered at the `AssistantTurnBubble` render gate (it's the normal case — no note needed). +- `stopReason: string | null` — set only for non-`end_turn` completions (`max_tokens`, `refusal`, `stop_sequence`, `pause_turn`, `interrupted`, `question_dismissed`, and the native harness's `empty_response` — an open set: unknown values render via the footer's generic fallback). Rendered inline as a footer under the affected turn; `null` means the turn completed normally. The transcript-watcher filters `tool_use` upstream; `end_turn` reaches the reducer but is filtered at the `AssistantTurnBubble` render gate (it's the normal case — no note needed). The single definition of "abnormal" is `abnormalStopReason` (exported from `AssistantTurnBubble.tsx`), shared by the bubble's footer gates and the ChatView/BubbleFeed timeline gates. + +**A turn is NOT guaranteed to have segments** (2026-08-21, empty-step recovery): turns are normally minted by content actions, but `TRANSCRIPT_TURN_COMPLETE` mints a **segment-less** turn when it arrives with `currentTurnId === null` and an abnormal `stopReason`, so a fully-contentless turn (the `empty_response` worst case) still gets its footer row instead of unexplained silence. That mint is idempotent by the action's `uuid` via `seenUuids` (the watcher re-emits turn-complete and replay re-delivers it; without the guard each replay would append a ghost turn). Consumers must not index `segments[0]` unchecked, and the ChatView/BubbleFeed gates drop a segment-less turn only when its stopReason is normal/absent. - `model: string | null` — Anthropic model ID (e.g. `claude-opus-4-7`). Captured on the first `TRANSCRIPT_ASSISTANT_TEXT` action (Task 2.4) and reconfirmed on `TRANSCRIPT_TURN_COMPLETE`. Drives (a) the opt-in per-turn metadata strip and (b) a reconciliation `useEffect` in App.tsx that silently updates the session-pill `sessionModels` when the transcript reveals drift (user typed `/model X` in the terminal, rate-limit downshift, session resume). - `usage: TurnUsage | null` — `{ inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens }` from `message.usage`. Populated on `TRANSCRIPT_TURN_COMPLETE`. Displayed only when the `showTurnMetadata` theme-context preference is on (default off — follows the "default hidden" precedent set by the derived StatusBar widgets). - `anthropicRequestId: string | null` — `req_…` from the transcript line's outer `requestId` field. Surfaced in `AttentionBanner` when state is `session-died` or `error` so the user can reference it when reporting issues. From fe514664196b49012158048e01668b993d1a2df1 Mon Sep 17 00:00:00 2001 From: Destin Date: Fri, 21 Aug 2026 22:33:46 -0700 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20second-review=20findings=20?= =?UTF-8?q?=E2=80=94=20withdraw=20orphaned=20preparing=20cards=20on=20empt?= =?UTF-8?q?y-step=20retry;=20one=20predicate=20for=20mint=20AND=20render?= =?UTF-8?q?=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of the first fix round found: - The empty 'tool-calls' shape (announced call, dropped as malformed) almost always leaves a 'Preparing…' card on screen, and the empty-step retry — unlike its manual-Retry and stall-retry siblings — never withdrew it: the step re-runs inside the same turn, so endTurn's reaping never fires and the orphan spins beside the retry's own cards until turn end. StepResult now carries pendingPreparing (started-but-never-completed call ids) out of the stream, and the retry withdraws them before re-running. Pinned by test. - abnormalStopReason was hand-inlined in the reducer (drift risk between the mint gate and the render gates). It now lives in chat-types.ts — shared by the reducer, the bubble's footer gates, and, via shouldRenderAssistantTurn, the ChatView/BubbleFeed timeline gates, which are now mirrored by construction instead of by comment. - The interrupt path's partial-text push was the last emptiness site still on truthiness; it now trims like the others (a whitespace-only partial is no partial at all). Known residual (deliberate, documented in the PR): rebuildHistory coalesces a whitespace-only step's persisted deltas into the retry step's text on resume — a cosmetic live-vs-rebuilt divergence, smaller than before this branch, left for a follow-up rather than touching the parity arbiter here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Lh1iH7j6qQDcHRFWMS1dhL --- desktop/src/main/harness/harness-session.ts | 35 +++++++++++++++++-- .../components/AssistantTurnBubble.tsx | 12 +------ desktop/src/renderer/components/ChatView.tsx | 14 ++++---- .../renderer/components/buddy/BubbleFeed.tsx | 10 +++--- desktop/src/renderer/state/chat-reducer.ts | 6 +++- desktop/src/renderer/state/chat-types.ts | 20 +++++++++++ desktop/tests/harness-session-loop.test.ts | 25 +++++++++++-- docs/chat-reducer.md | 2 +- 8 files changed, 94 insertions(+), 30 deletions(-) diff --git a/desktop/src/main/harness/harness-session.ts b/desktop/src/main/harness/harness-session.ts index 9aedf6f4..7718efce 100644 --- a/desktop/src/main/harness/harness-session.ts +++ b/desktop/src/main/harness/harness-session.ts @@ -244,6 +244,14 @@ interface StepResult { * first chunk) and everything outside the stream (tool execution, permission * waits). 0 when the step produced no output. */ generationMs: number; + /** Preparing cards this step put on screen whose tool call never COMPLETED + * (tool-input-start with no matching 'tool-call' part — announced, then + * dropped as malformed/truncated). Carried out of the stream so the + * empty-step retry in the turn loop can withdraw them: the step re-runs + * INSIDE the same turn, so endTurn's reaping never fires and an orphaned + * card would spin beside the retry's own cards until the turn ends (the + * same reason the manual-Retry and stall-retry paths withdraw theirs). */ + pendingPreparing: { toolCallId: string; toolName: string; chars: number }[]; } // v7 stream parts carry the chunk in .text (verified against ai@7.0.22: @@ -1718,7 +1726,10 @@ export class HarnessSession extends EventEmitter { // v0 interrupt semantics: push the partial, emit user-interrupt, return. // (An interrupted turn NEVER completes as a normal turn-complete.) if (step.interrupted || this.interrupted || this.abort.signal.aborted) { - if (step.text) this.history.push({ role: 'assistant', content: step.text }); + // trim() gate: same emptiness class as stepHasText below — a + // whitespace-only partial is no partial at all, and recording it + // leaves a junk assistant message in history for every later turn. + if (step.text && step.text.trim().length > 0) this.history.push({ role: 'assistant', content: step.text }); this.emitEvent('user-interrupt', {}); return; } @@ -1767,6 +1778,19 @@ export class HarnessSession extends EventEmitter { if (isEmptyStep && orderlyFinish) { consecutiveEmptySteps++; if (consecutiveEmptySteps === 1) { + // Withdraw any preparing card the dead step left on screen — the + // 'tool-calls' empty shape (announced call, dropped as malformed) + // almost always put one up. The step re-runs INSIDE the same turn, + // so endTurn's reaping never fires and the orphan would spin beside + // the retry's own cards until the turn ends. (Same reason the + // manual-Retry and stall-retry paths withdraw theirs; the + // empty_response break below needs no withdrawal — the turn ends + // there and endTurn reaps.) + for (const prep of step.pendingPreparing) { + this.emitEvent('assistant-thinking', { + toolPreparing: { toolCallId: prep.toolCallId, toolName: prep.toolName, chars: prep.chars, cleared: true }, + }); + } // One structured log line so the silent retry is diagnosable from // ~/.claude/desktop.log (console.error reaches nobody in a packaged // build) — deliberately NOT a transcript event (emit surface frozen). @@ -2395,14 +2419,21 @@ export class HarnessSession extends EventEmitter { if (interrupted || this.interrupted || abortSignal.aborted) { // Don't await usage/finishReason on the interrupt path — the stream was // torn down; those promises may never settle. - return { text: assistantText, toolCalls, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }, finishReason: undefined, interrupted: true, generationMs: firstChunkAt ? Date.now() - firstChunkAt : 0 }; + return { text: assistantText, toolCalls, usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 }, finishReason: undefined, interrupted: true, generationMs: firstChunkAt ? Date.now() - firstChunkAt : 0, pendingPreparing: [] }; } const usage = await result.usage; const finishReason = await result.finishReason; + // `preparing` entries are NOT deleted when their call completes (the card + // transitions in place under the same id), so filter by completed + // toolCalls to find the truly orphaned ones. Empty in the common case. + const pendingPreparing = [...preparing] + .filter(([prepId]) => !toolCalls.some((c) => c.toolCallId === prepId)) + .map(([prepId, entry]) => ({ toolCallId: prepId, toolName: entry.toolName, chars: entry.chars })); return { text: assistantText, toolCalls, + pendingPreparing, usage: { inputTokens: usage?.inputTokens ?? 0, outputTokens: usage?.outputTokens ?? Math.ceil(outputChars / APPROX_CHARS_PER_TOKEN), diff --git a/desktop/src/renderer/components/AssistantTurnBubble.tsx b/desktop/src/renderer/components/AssistantTurnBubble.tsx index e8ad7f27..8924977f 100644 --- a/desktop/src/renderer/components/AssistantTurnBubble.tsx +++ b/desktop/src/renderer/components/AssistantTurnBubble.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { AssistantTurn } from '../state/chat-types'; +import { AssistantTurn, abnormalStopReason } from '../state/chat-types'; import { ToolCallState, ToolGroupState, SessionProvider } from '../../shared/types'; import { assistantName } from '../utils/assistant-name'; import MarkdownContent from './MarkdownContent'; @@ -55,16 +55,6 @@ function stopReasonCopy(reason: string, provider: SessionProvider | undefined): return map[reason] ?? `Response ended: ${reason}.`; } -// The single definition of "a stopReason worth surfacing" — `end_turn` is the -// normal completion and carries no signal. Shared by this component's two -// footer gates AND the timeline gates in ChatView / buddy BubbleFeed, which -// must let a segment-less turn through exactly when this returns true (a -// segment-less turn that renders nothing here would be dropped-then-mounted -// for no reason; one that renders a footer must NOT be dropped upstream — -// that exact mismatch shipped the empty_response footer as dead code once). -export function abnormalStopReason(reason: string | null | undefined): boolean { - return !!reason && reason !== 'end_turn'; -} // Collapsible disclosure for the model's reasoning / chain of thought. // Collapsed by default — user explicitly chose this UX so reasoning doesn't diff --git a/desktop/src/renderer/components/ChatView.tsx b/desktop/src/renderer/components/ChatView.tsx index dcfd9dd7..bf8200fd 100644 --- a/desktop/src/renderer/components/ChatView.tsx +++ b/desktop/src/renderer/components/ChatView.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { useChatState, useChatDispatch } from '../state/chat-context'; -import { HISTORY_EXPAND_PROMPT_ID } from '../state/chat-types'; +import { HISTORY_EXPAND_PROMPT_ID, shouldRenderAssistantTurn } from '../state/chat-types'; import UserMessage from './UserMessage'; import SpecialistReportCard from './SpecialistReportCard'; import QueuedMessagesStrip from './QueuedMessagesStrip'; -import AssistantTurnBubble, { abnormalStopReason } from './AssistantTurnBubble'; +import AssistantTurnBubble from './AssistantTurnBubble'; import ToolCard from './ToolCard'; import PromptCard, { PromptCardButton } from './PromptCard'; import { sendPromptInput } from '../state/prompt-input'; @@ -790,12 +790,10 @@ export default function ChatView({ sessionId, visible, sessionActive, resumeInfo break; case 'assistant-turn': { const turn = state.assistantTurns.get(entry.turnId); - // A segment-less turn normally renders nothing — EXCEPT when - // it carries an abnormal stopReason, whose footer row is the - // whole fix for the empty_response bug (a fully-contentless - // turn must not end in unexplained silence). Same predicate - // as AssistantTurnBubble's own zero-bubble gate. - if (!turn || (turn.segments.length === 0 && !abnormalStopReason(turn.stopReason))) return null; + // Shared gate (chat-types.ts): a segment-less turn renders + // only when its abnormal stopReason gives the footer row + // something to say — the empty_response fix. + if (!shouldRenderAssistantTurn(turn)) return null; key = entry.turnId; content = ( 0 || abnormalStopReason(turn.stopReason); +} + // Snapshot of session stats + rate limits captured when /cost or /usage was typed. // Point-in-time — never auto-updates. The live view lives in the status bar. export interface UsageSnapshot { diff --git a/desktop/tests/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts index 2d4ef66a..7229231e 100644 --- a/desktop/tests/harness-session-loop.test.ts +++ b/desktop/tests/harness-session-loop.test.ts @@ -1904,13 +1904,34 @@ describe('HarnessSession — empty final step recovery', () => { expect(JSON.stringify(history[1])).toContain('recovered'); }); - it("finishReason 'tool-calls' with ZERO parsed calls: orderly → retried (review fix)", async () => { + it("finishReason 'tool-calls' with ZERO parsed calls: orderly → retried, preparing card withdrawn (review fix)", async () => { // A stream that announces tool use but whose every call was dropped as // malformed/truncated leaves toolCalls empty with finishReason // 'tool-calls' — the likeliest empty-step shape on small local models. // Excluding it from ORDERLY_EMPTY_FINISHES ended the turn with the raw // passthrough stopReason 'tool-calls' (meaningless to the user) instead of - // the retry ladder. + // the retry ladder. And because this shape almost always put a "Preparing…" + // card on screen (tool-input-start with no completed tool-call), the retry + // must WITHDRAW that card before re-running — the step re-runs inside the + // same turn, so endTurn's reaping never fires and the orphan would spin + // beside the retry's own cards (same rule as the manual/stall retry paths). + const seen: any[] = []; + const model = scriptedModel([ + stream(...toolInputChunks('c1', 'Read', '{"file_pa'), finishChunk('tool-calls')), + stream(...textChunks('a', 'recovered'), finishChunk('stop')), + ], seen); + const session = new HarnessSession(makeOpts({}), async () => model as any); + const events = collect(session); + await session.send('go'); + + expect(seen).toHaveLength(2); // one retry, then the real answer + expect(events.find((e) => e.type === 'turn-complete')!.data.stopReason).toBe('end_turn'); + // The dead attempt's preparing card was explicitly withdrawn. + const cleared = events.filter((e) => e.type === 'assistant-thinking' && e.data.toolPreparing?.cleared); + expect(cleared.map((e) => e.data.toolPreparing.toolCallId)).toEqual(['c1']); + }); + + it("empty 'tool-calls' twice → empty_response (the honest end, not the raw passthrough)", async () => { const seen: any[] = []; const model = scriptedModel([stream(finishChunk('tool-calls'))], seen); const session = new HarnessSession(makeOpts({}), async () => model as any); diff --git a/docs/chat-reducer.md b/docs/chat-reducer.md index 0ed19ccd..630de238 100644 --- a/docs/chat-reducer.md +++ b/docs/chat-reducer.md @@ -67,7 +67,7 @@ Guard: `chat-reducer.test.ts` → "chatReducer tool card duplication". Historica `AssistantTurn` carries four fields populated from the JSONL transcript: -- `stopReason: string | null` — set only for non-`end_turn` completions (`max_tokens`, `refusal`, `stop_sequence`, `pause_turn`, `interrupted`, `question_dismissed`, and the native harness's `empty_response` — an open set: unknown values render via the footer's generic fallback). Rendered inline as a footer under the affected turn; `null` means the turn completed normally. The transcript-watcher filters `tool_use` upstream; `end_turn` reaches the reducer but is filtered at the `AssistantTurnBubble` render gate (it's the normal case — no note needed). The single definition of "abnormal" is `abnormalStopReason` (exported from `AssistantTurnBubble.tsx`), shared by the bubble's footer gates and the ChatView/BubbleFeed timeline gates. +- `stopReason: string | null` — set only for non-`end_turn` completions (`max_tokens`, `refusal`, `stop_sequence`, `pause_turn`, `interrupted`, `question_dismissed`, and the native harness's `empty_response` — an open set: unknown values render via the footer's generic fallback). Rendered inline as a footer under the affected turn; `null` means the turn completed normally. The transcript-watcher filters `tool_use` upstream; `end_turn` reaches the reducer but is filtered at the `AssistantTurnBubble` render gate (it's the normal case — no note needed). The single definition of "abnormal" is `abnormalStopReason` (exported from `chat-types.ts`), shared by the reducer's turn-complete mint gate, the bubble's footer gates, and — via `shouldRenderAssistantTurn` in the same file — the ChatView/BubbleFeed timeline gates. **A turn is NOT guaranteed to have segments** (2026-08-21, empty-step recovery): turns are normally minted by content actions, but `TRANSCRIPT_TURN_COMPLETE` mints a **segment-less** turn when it arrives with `currentTurnId === null` and an abnormal `stopReason`, so a fully-contentless turn (the `empty_response` worst case) still gets its footer row instead of unexplained silence. That mint is idempotent by the action's `uuid` via `seenUuids` (the watcher re-emits turn-complete and replay re-delivers it; without the guard each replay would append a ghost turn). Consumers must not index `segments[0]` unchecked, and the ChatView/BubbleFeed gates drop a segment-less turn only when its stopReason is normal/absent. - `model: string | null` — Anthropic model ID (e.g. `claude-opus-4-7`). Captured on the first `TRANSCRIPT_ASSISTANT_TEXT` action (Task 2.4) and reconfirmed on `TRANSCRIPT_TURN_COMPLETE`. Drives (a) the opt-in per-turn metadata strip and (b) a reconciliation `useEffect` in App.tsx that silently updates the session-pill `sessionModels` when the transcript reveals drift (user typed `/model X` in the terminal, rate-limit downshift, session resume).