Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 107 additions & 4 deletions desktop/src/main/harness/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
61 changes: 61 additions & 0 deletions desktop/src/renderer/components/AssistantTurnBubble.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChatProvider>
<AssistantTurnBubble
turn={{ ...turnWithStopReason('empty_response'), segments: [], timestamp: Date.UTC(2026, 7, 21, 12, 0, 0) }}
toolGroups={new Map()}
toolCalls={new Map()}
sessionId="test"
showTimestamps={true}
/>
</ChatProvider>
);
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('');
});
});
44 changes: 41 additions & 3 deletions desktop/src/renderer/components/AssistantTurnBubble.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 (
<div className="flex justify-start px-4 py-0.5">
<div className="max-w-[85%]">
{showTurnMetadata && <TurnMetadataStrip turn={turn} />}
<StopReasonFooter reason={turn.stopReason!} provider={provider} />
{/* 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 && (
<div className="bubble-timestamp text-4xs text-fg-muted/60 text-right mt-1 -mb-0.5 select-none leading-none">
{formatBubbleTime(turn.timestamp)}
</div>
)}
</div>
</div>
);
}

return (
<>
{bubbles.map((bubble, i) => {
Expand Down Expand Up @@ -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' && <StopReasonFooter reason={turn.stopReason} provider={provider} />}
{isLastBubble && abnormalStopReason(turn.stopReason) && <StopReasonFooter reason={turn.stopReason!} provider={provider} />}
{showTimestamps && isLastBubble && turn.timestamp && (
<div className="bubble-timestamp text-4xs text-fg-muted/60 text-right mt-1 -mb-0.5 select-none leading-none">
{formatBubbleTime(turn.timestamp)}
Expand Down
7 changes: 5 additions & 2 deletions desktop/src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 = (
<AssistantTurnBubble
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/renderer/components/buddy/BubbleFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { hookEventToAction } from '../../state/hook-dispatcher';
import UserMessage from '../UserMessage';
import SpecialistReportCard from '../SpecialistReportCard';
import AssistantTurnBubble from '../AssistantTurnBubble';
import { shouldRenderAssistantTurn } from '../../state/chat-types';
import { CompactToolStrip } from './CompactToolStrip';
import PromptCard from '../PromptCard';
import { sendPromptInput } from '../../state/prompt-input';
Expand Down Expand Up @@ -390,7 +391,9 @@ export function BubbleFeed({ sessionId }: Props) {
break;
case 'assistant-turn': {
const turn = state.assistantTurns.get(entry.turnId);
if (!turn || turn.segments.length === 0) return null;
// Shared gate (chat-types.ts) — one function keeps this
// mirrored with ChatView.tsx by construction.
if (!shouldRenderAssistantTurn(turn)) return null;
key = entry.turnId;
content = (
<AssistantTurnBubble
Expand Down
Loading
Loading