fix(run): a non-interactive turn must end with text, even after a tool failure

Merged
SA
2mo ago
+261-9

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.

  • run tracks 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 bash failed (…). 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 a silent_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): scripted bash call → 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.

  • run treats 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, run sends 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 after bash failed.) 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 json runs 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.