fix(run): a non-interactive turn must end with text, even after a tool failure
Issue for this PR
Closes #1334
Type of change
- Bug fix
What does this PR do?
Headless run: when a tool call fails — auto-rejected because nobody can approve, or a plain tool failure with --yolo — the model often stops with no text, and the process prints nothing and exits 0. The rejection is already handed back to the model as a tool error (so "return it as a normal tool result" from the issue was already true); what was missing is a reply.
runtracks whether the assistant produced any text and the last tool failure (tool,error).- If the turn ends silent, it sends one synthetic reply turn through the same path the idle-done challenge uses, with a directive that names the failed tool: "Your previous turn ended after the tool call
bashfailed (…). Do not retry that tool. Answer the user's request now, in text, with what you already have: … say plainly what was attempted and what could not be completed and why." - If the model still says nothing, a synthesised line —
No answer was produced: the turn ended after \bash` failed (…)— goes to stdout (and--output, and as asilent_turnevent in--format json`), and the run exits 1 so a script can tell "no answer" from "answered". A turn that answered normally is untouched. - One-shot: the follow-up cannot breed further follow-ups.
The first cut set the "text seen" flag after the JSON-mode emit, which continues past the rest of the handler — every --format json run then looked silent and got a spurious follow-up. Fixed; the existing --format json subprocess test caught it.
Not in this PR: the model reaching for bash (altimate-dbt, git) where native tools exist — a prompt/tool-description matter noted in the issue as a second problem.
How did you verify your code works?
test/cli/run/silent-turn.test.ts(subprocess, scripted LLM): scriptedbashcall → auto-rejected → empty reply → follow-up answer is printed, exit 0; the same with a second empty reply → synthesised line on stdout and exit 1; a normal answer sends no follow-up (a queued "SHOULD NOT BE REQUESTED" reply is never consumed).test/session/termination-silent-turn.test.ts: the directive names the tool and error, forbids retrying, asks for text; flattens and bounds a long error.- Disabled the reply path once: both silent-turn tests fail. Restored.
run-process,run-accounting,run-mode,termination*: 126 pass across three combined runs. Typecheck and marker guard clean.
Screenshots / recordings
N/A (CLI). Example of the new stderr/stdout shape from the test run:
! permission requested: bash (altimate-dbt info); auto-rejecting
✗ bash failed
! the turn ended without a reply after `bash` failed — asking for one
I could not run altimate-dbt (permission was denied), but from the files read: fix orders.sql first.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
🤖 Generated with Claude Code
https://claude.ai/code/session_012Q51zFUmPg1WwtS5CrGJE6
Summary by cubic
Fixes headless run so a turn that ends with no assistant text — typically after a tool call fails or is auto-rejected (nobody can approve in headless mode) — still produces an answer instead of printing nothing and exiting 0. The rejection was already returned to the model as a tool error; what was missing was the reply.
runtreats a turn as unanswered unless its last step produced visible assistant text; a "Let me check…" preamble in an earlier step, compaction summaries, and zero-width-only text do not count.- If the turn ends silent,
runsends one synthetic reply turn naming the failed tool and telling the model not to retry it; the tool's diagnostic stays in the tool result and is not repeated in the prompt or in--output. - If the model still says nothing, a synthesised line (
No answer was produced: the turn ended afterbashfailed.) goes to stdout and--output, and the run exits 1 so scripts can distinguish "no answer" from "answered". A turn that answered normally is untouched. - The "text seen" flag is set before the JSON-mode
emit, so--format jsonruns aren't spuriously treated as silent. Any failure in the follow-up reply — subscription, mid-stream, or send — is printed and emitted as the run's own error rather than silently recorded.
Closes #1334.
Summary by CodeRabbit
-
Bug Fixes
- Non-interactive runs now correctly identify turns without a final assistant response, including turns with pre-tool text.
- Failed tool calls receive a follow-up response attempt, with failures surfaced clearly when recovery also produces no answer.
- Recovery prompts identify the failed tool without repeating its diagnostic details.
-
Tests
- Added coverage for silent turns, failed tools, streamed pre-tool text, normal responses, and diagnostic suppression.