fix(dashboard): stop refusal recovery re-answering an answered turn - #8275
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All checks done. The final verification confirmed: First-Principles-Verdict: PASS A reported live defect — the agent restating its answer once per blocked call — fixed at the wrong-premise text that caused it, nothing riding along. What this change shipsIntent: stop the automatic post-block recovery turn from making the agent re-answer a question it already answered. This is a FIX.
Counts run: [FIRST-PRINCIPLES-REVIEWED] 5963166 |
Design Review (Fable 5) — ✅ PASSDesign-level review of The design is coherent: the steer-delivered case already skips recovery entirely ( Design-Verdict: PASS Targets a real observed harm at the actual defect — the recovery text's false premise — while preserving the reason-delivery guarantee the turn exists for. [DESIGN-REVIEWED] 5963166 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
dfb8372 to
adda670
Compare
|
Disposition: fixed —
The finding holds, and it is this PR's own bug on the mirror-image ordering. Three What changed. A third turn-level flag, Pinned by One consequence the fix surfaced, closed in the same commit: a flushed segment proves |
A tool call blocked by a safety policy queues a [Tool refusal - automatic recovery] continuation, because the backend's own tool result for a rejected permission is the fixed string "User denied tool execution" and on a backend without mid-turn steer that continuation is the only channel for the real reason. Its body states the block "ended the turn early" and closes with "continue the task where you left off". Both are false for a turn that went on to answer anyway, and acting on them makes the model answer a question the user has already read - once per blocked call, each a full billed turn. The prompt builder gains `answered`. When the turn produced its own answer, the block reasons and the per-class remediation still go out, but the body is framed as awareness and forbids restating the delivered answer. The unanswered wording is unchanged, and no turn cap is added: a cap is the "skip the notice" direction the queue guard's documented asymmetry rules out.
adda670 to
5963166
Compare
chenmingwei23
left a comment
There was a problem hiding this comment.
Approved: PR Readiness green on this head, all check-runs pass on best-per-name.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem / Motivation
When a tool call is blocked by a Kiro Crew safety policy, the chat runner queues a
[Tool refusal — automatic recovery]continuation so the model learns the realreason — the backend's own tool result for a rejected permission is the fixed
string
User denied tool execution, and on a backend without mid-turn steer(
ACP_BACKENDS_STEERomits the claude backend, sosupports_steerisFalseandnotices_sentis always 0) that continuation is the only channel for it.The body of that continuation asserts the block "ended the turn early" and closes
with "continue the task where you left off". Both are false for a turn that went on
to answer anyway — the model routed around the block, or already had enough
evidence without the blocked call. Acting on the instruction makes it re-answer a
question the user has already read.
Observed in a live session: three separate blocked calls in one investigation
produced three recovery turns, and the user saw the same verdict written out three
times.
Why it matters
Every duplicate is a full billed turn, and the count scales with the number of
blocked calls in the turn rather than with the number of open questions. The
user-visible damage is worse than the cost: an answer restated three times reads as
the agent losing track of the conversation, and it buries the one thing the recovery
turn exists to deliver — the block reason.
What changed (motivation → approach → change)
Symptom: a turn that answered despite a policy block answers again, once per
blocked call.
Root cause: the recovery body has exactly one wording, and its premise ("the
turn ended early") is true in only one of the two cases that reach it. The decision
to queue was right; the text was wrong about what had happened.
Change:
build_refusal_recovery_prompt()insrc/kiro_crew/dashboard/state.pygains anansweredflag. When it is set, theblocked-call list and the whole per-class
remediation_forguidance block still goout unchanged, but the framing becomes awareness rather than resumption, and the
closing instruction becomes an explicit prohibition on restating what was already
sent. The unanswered wording is reproduced byte-for-byte, so no existing recovery is
altered.
The flag means text was sent, never the task is done, and the body is worded to
match: continuing from there is the default, and the one-line-and-stop case is
conditioned explicitly on the task being finished. That distinction is load-bearing
because there is no way to tell a delivered answer from a one-line preamble ("Let me
check the logs.") before the blocked call — both are plain prose flushed at the same
point in the stream — so a body that asserted a finished answer would tell a turn
that had only narrated its intent to stop with the work undone.
The single call site in
src/kiro_crew/dashboard/chat_runner.pypassesanswered=bool(_answer_text.strip()) or _produced_visible_output or _turn_flushed_visible_text._answer_textis the turn's own answer with backendcontrol notices removed;
_produced_visible_outputcovers the paths that resetassistant_textafter emitting it without a tool boundary (steer cut, compaction,clear, agent switch);
_turn_flushed_visible_textis new and covers the threetool-boundary flushes (post-tool-group text,
EVENT_TOOL_CALL, the permission flow),which persist the segment and reset the buffer — so answer → blocked call → turn
end would otherwise reach the predicate with an empty
assistant_textand be scoredunanswered. It is a separate flag rather than a widening of
_produced_visible_outputbecause that flag's narrow meaning is what the promise-only guard below it depends on.
Two things deliberately NOT done, because the existing design rules them out:
git log -Sforrefusal_recovery_depth,max_refusal,recovery_depthfinds nothing), and theabsence is a choice rather than an omission:
should_queue_refusal_recovery'sdocstring records the asymmetry it was written for — skipping wrongly leaves the
model with
User denied tool executionand no correction, while queueing wronglycosts one turn the model would otherwise have been told twice. A cap is the
skipping direction, and it bites hardest exactly when the model is most confused
(after several blocks in a row). The sibling Stop-hook path does have
agent.max_stop_hook_nudges, for a reason that does not transfer: a hook is anexternal program that can return
blockunconditionally forever with zero modelagency, whereas a refusal chain requires the model to freely choose a fresh
blocked call each turn.
with it, leaving the model's last word on the subject as
User denied tool execution— which is how it starts attributing policy blocks to the user in laterturns. Awareness-without-redo delivers the reason at the same one-turn cost the
design already accepted.
No frontend change is needed:
RecoveryCard.tsxdispatches on the prefix and counts-bullets, both unchanged, and the default body is byte-identical, so thecapture-recovery-card.mjsandRecoveryCard.test.tsxfixtures still match.Tests
test/test_dashboard_approval.py:test_answered_turn_gets_awareness_body_not_a_continuation— the reason is stillpresent,
"ended the turn early"and"continue the task where you left off"aregone, the awareness framing and
"Do NOT repeat"are present.test_unanswered_turn_keeps_the_continuation_body— pins the default wording so alater edit cannot collapse the two modes into one.
test_answered_keeps_per_class_remediation— equal"How to do this properly:"counts in both modes, so the awareness path cannot quietly drop the remediation
guidance that is the reason the turn is dispatched at all.
test_deny_after_an_answer_injects_awareness_not_a_redo— drives the real_run_chatwith aToolHookResult.deny(...)and a first stream that emits text,then asserts the injected recovery row carries the reason and the prohibition but
not the redo instruction. This is the one that proves
answered=Trueis reached onthe production path, not only in the builder.
test_answer_before_the_block_also_gets_awareness_not_a_redo— the ordering thepredicate previously missed: text streamed before the blocked call, so the
tool-boundary flush has emptied
assistant_textby end of turn. Revertingor _turn_flushed_visible_textfails it withassert 'continue th...you left off' not in ....test/test_deny_guidance.py:test_answered_body_never_tells_an_unfinished_turn_to_stop— locks thesent-vs-finished distinction into the body itself: the awareness branch keeps
"Do NOT repeat", still avoids the resume instruction, and must makecontinue-from-there the default with the stop case explicitly conditioned on the
task being finished. Without it, a turn that only narrated its intent before the
block could be told to stop with the work undone.
Manual verification
N/A — unit coverage sufficient: the last test drives the real
_run_chatdeny pathend to end, which is the integration this change lives on.
Local pre-push review, stated so the server lanes are read with it in mind: the
opuslane ran on its pinnedclaude-opus-4.8against the extractedclaude-review.ymlcontract and returned no findings. Thegptlane did notrun. This gateway rejects
gpt-5.6-sol,gpt-5.6-terraandgpt-5.6-lunawithInvalid value for config option model, andgrok-4.6too, so no model in or nearthe
gpt-5.xtier was reachable; the retry onclaude-opus-4.8was stopped beforeit finished. The server-side
codex-reviewlane is therefore the first GPT pass onthis diff — treat its verdict as unpreviewed rather than as a re-run of a local
green.
Related Issues
no linked issue: found while triaging a live session, not tracked.
Pattern harvest
Rule candidate:
review-promptPattern: an automatic continuation whose text asserts a precondition ("the turn
ended early", "you were interrupted") that the code queuing it never checked — the
decision to queue and the claim about why it was queued need the same evidence.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)