diff --git a/desktop/src/main/harness/harness-session.ts b/desktop/src/main/harness/harness-session.ts
index e574f8b2..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:
@@ -265,6 +273,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
@@ -1620,6 +1641,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
@@ -1701,17 +1726,88 @@ 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;
}
+ // 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));
}
+ // 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 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 &&
+ !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
+ || ORDERLY_EMPTY_FINISHES.has(step.finishReason);
+ 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).
+ 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
+ // 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.
@@ -2323,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.test.tsx b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx
index f458d7f6..4173d2e1 100644
--- a/desktop/src/renderer/components/AssistantTurnBubble.test.tsx
+++ b/desktop/src/renderer/components/AssistantTurnBubble.test.tsx
@@ -383,4 +383,65 @@ 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 twice. 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');
+ });
+
+ 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 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', () => {
+ // 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 430a30fb..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';
@@ -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,10 +42,20 @@ 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 —
+ // "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 twice. Retrying may help.',
};
return map[reason] ?? `Response ended: ${reason}.`;
}
+
// 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.
@@ -369,6 +379,34 @@ 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 (!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)}
+
+ )}
+
+
+ );
+ }
+
return (
<>
{bubbles.map((bubble, i) => {
@@ -427,7 +465,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..bf8200fd 100644
--- a/desktop/src/renderer/components/ChatView.tsx
+++ b/desktop/src/renderer/components/ChatView.tsx
@@ -1,6 +1,6 @@
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';
@@ -790,7 +790,10 @@ 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;
+ // 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 = (
{
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([]);
+ });
+
+ 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 91d77a51..f2e828f2 100644
--- a/desktop/src/renderer/state/chat-reducer.ts
+++ b/desktop/src/renderer/state/chat-reducer.ts
@@ -8,6 +8,7 @@ import {
createSessionChatState,
deserializeChatState,
HISTORY_EXPAND_PROMPT_ID,
+ abnormalStopReason,
} from './chat-types';
import { SubagentSegment, ToolCallState, ToolGroupState } from '../../shared/types';
@@ -1404,14 +1405,54 @@ 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;
- const assistantTurns = new Map(session.assistantTurns);
- if (completingTurnId) {
- const turn = assistantTurns.get(completingTurnId);
+ // 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`).
+ // Shared predicate (chat-types.ts): the mint below and the render gates
+ // must agree on what "abnormal" means, or a minted turn gets dropped —
+ // or a droppable one minted.
+ const abnormalStop = abnormalStopReason(action.stopReason);
+ let assistantTurns = new Map(session.assistantTurns);
+ let timeline = session.timeline;
+ 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
@@ -1420,11 +1461,12 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
model: action.model ?? turn.model,
anthropicRequestId: action.anthropicRequestId,
usage: action.usage,
+ ...(mintedTimestamp !== null ? { timestamp: mintedTimestamp } : {}),
});
}
}
- next.set(action.sessionId, { ...session, ...endTurn(session, undefined, assistantTurns) });
+ next.set(action.sessionId, { ...session, timeline, seenUuids, ...endTurn(session, undefined, assistantTurns) });
return next;
}
diff --git a/desktop/src/renderer/state/chat-types.ts b/desktop/src/renderer/state/chat-types.ts
index e88d82a1..84129692 100644
--- a/desktop/src/renderer/state/chat-types.ts
+++ b/desktop/src/renderer/state/chat-types.ts
@@ -77,6 +77,26 @@ export interface AssistantTurn {
anthropicRequestId: string | null;
}
+/** The single definition of "a stopReason worth surfacing" — `end_turn` is the
+ * normal completion and carries no signal. Lives HERE (not in a component) so
+ * the reducer's turn-complete mint gate and the render gates below share one
+ * predicate: a minted turn the gates drop, or a droppable turn that mints,
+ * is exactly the divergence that shipped the empty_response footer as dead
+ * code once (PR #324 review). */
+export function abnormalStopReason(reason: string | null | undefined): boolean {
+ return !!reason && reason !== 'end_turn';
+}
+
+/** Timeline render gate shared by ChatView and the buddy BubbleFeed (which
+ * MUST mirror each other): a segment-less turn renders only when it carries
+ * an abnormal stopReason — its footer row is the whole fix for the
+ * empty_response bug (a fully-contentless turn must not end in unexplained
+ * silence). All other segment-less turns drop. */
+export function shouldRenderAssistantTurn(turn: AssistantTurn | undefined): turn is AssistantTurn {
+ if (!turn) return false;
+ return turn.segments.length > 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/chatview-empty-response-gate.test.tsx b/desktop/tests/chatview-empty-response-gate.test.tsx
new file mode 100644
index 00000000..909980a6
--- /dev/null
+++ b/desktop/tests/chatview-empty-response-gate.test.tsx
@@ -0,0 +1,106 @@
+// @vitest-environment jsdom
+// Empty-step recovery (spec 2026-08-21, decision 4) — review fix, PR #324.
+//
+// The unit tests that shipped with the recovery ladder mounted
+// AssistantTurnBubble DIRECTLY and asserted on reducer state — so they all
+// passed while the feature was dead in the real app: ChatView's timeline gate
+// (`if (!turn || turn.segments.length === 0) return null;`) dropped every
+// segment-less turn before AssistantTurnBubble ever mounted, which is exactly
+// the shape the empty_response footer exists for. This test crosses the
+// ChatView boundary: state in, rendered footer out.
+//
+// Scaffolding mirrors chat-pane-layout-containment.test.tsx (the established
+// ChatView mounting pattern): chat-context and app-wide contexts are mocked,
+// jsdom gets an IntersectionObserver stub.
+import React from 'react';
+import { describe, it, expect, vi } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+
+function emptySessionState() {
+ return {
+ timeline: [] as any[],
+ queuedMessages: [] as any[],
+ toolCalls: new Map(),
+ toolGroups: new Map(),
+ assistantTurns: new Map(),
+ activeTurnToolIds: new Set(),
+ isThinking: false,
+ promptProcessing: null,
+ attentionState: 'ok',
+ errorMessage: null,
+ stallWarning: null,
+ lastActivityAt: 0,
+ lastOutputAt: 0,
+ modelState: 'idle',
+ modelInfo: null,
+ modelLoadedBytes: 0,
+ modelEverResident: false,
+ };
+}
+
+const mocks = vi.hoisted(() => ({ 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');
+ });
+});
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/harness-session-loop.test.ts b/desktop/tests/harness-session-loop.test.ts
index abcd5fa2..7229231e 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
@@ -1703,3 +1703,242 @@ 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 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.
+ // 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'
+ });
+
+ // 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');
+ });
+
+ 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, 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. 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);
+ 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');
+ });
+});
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) {
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
]);
diff --git a/docs/chat-reducer.md b/docs/chat-reducer.md
index e0d5e45b..630de238 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 `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).
- `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.