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.