fix(chat): stop replaying a productive turn as an empty response - #8553
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A field-observed side-effect-duplication bug fixed at the exact decision point that caused it, with rejected alternatives explained and the recovery bound preserved. The guard is scoped correctly: broadening [DESIGN-REVIEWED] be245e3 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/dashboard/chat_runner.py:10050 -- False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings that block; one advisory. FINDING — src/kiro_crew/dashboard/chat_runner.py:10029 — a depth-0 productive empty turn that skips rung 1 ( [OPUS-REVIEWED] be245e3 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 The empty-response ladder exists only in First-Principles-Verdict: PASS A reported replay-of-completed-side-effects defect is fixed at cause level, and every rider carries a harm named by the same incident. What this change shipsIntent: stop the runner from silently re-running a user's message — and its completed tool calls — after a turn that did work but ended with an empty final segment. This is a FIX.
Duplication checks run: [FIRST-PRINCIPLES-REVIEWED] be245e3 |
A turn that streamed text and then called a tool arrived at the terminal chain with an empty final segment, because `assistant_text` is reset at every tool boundary and nothing recorded that visible output had already been flushed. The empty-response ladder read that as "the model returned nothing" and re-queued the ORIGINAL user message, which re-runs tool calls that already completed and re-derives an answer the user has already read. A productive turn (flushed visible text, a dispatched tool call, or thinking) can no longer take the verbatim-replay rung. It gets at most ONE continuation, worded so the model is not told its completed work produced no output, and that continuation consumes the remaining recovery budget. A genuinely activity-free empty turn keeps the existing bounded replay -> continue -> give-up ladder unchanged. The verdict also had no cause. Five physically different realities -- a provider that generated nothing, a terminal carrying no stop reason, no terminal event at all, a synthesized terminal, and a turn whose output was only tools or thinking -- collapsed onto one warning that printed none of the state the runner already held. Each now reports a closed cause and the rung it took, alongside booleans only: no prompt, no response text, no thinking, no tool arguments or results, no paths, no identities, no token counts and no costs. Two ACP-side silences are closed the same way: the pre-turn drain says how many leftover frames it destroyed (a count, never their contents), and a prompt stream that exhausts cleanly without a terminal completion now says so, which is the one state the dashboard cannot distinguish from a model that answered with nothing.
535f6bb to
be245e3
Compare
buluoray
left a comment
There was a problem hiding this comment.
Reviewed against source at be245e3, with the new guards mutation-tested rather than taken on trust. 0 blocking findings.
The fix is at the right decision point. chat_runner.py:9942 sets _may_replay_verbatim = not _empty_activity.productive and gates the rung-1 verbatim replay on it at :9943, where productive = flushed_visible or had_tools or had_thinking. A turn that already emitted visible text or ran tools no longer gets its message replayed as a fresh empty response; a genuinely activity-free turn keeps the old recovery. The recovery budget is preserved by forcing _empty_response_retries = 2 on the productive path, so exactly one continuation follows and a second cannot.
Mutation results — every new test pins its intended behavior, and the scoping tests correctly stay green:
| Mutation (source) | Result |
|---|---|
_may_replay_verbatim = not …productive → = True |
RED: the 3 productive tests; test_activity_free…keeps…rung stays GREEN |
Drop counter-forcing (= 2 → += 1) |
RED: test_text_then_tool_turn_continues… |
normalize_stop_reason: absent → _STOP_END_TURN |
RED: 2 tests; the provider-empty distinctness test stays GREEN |
ACP drain _stale_dropped += 1 → += 0 |
RED: test_pre_turn_drain_counts_discarded_frames… |
Also verified: _produced_visible_output was correctly not reused (all 18 uses are mid-turn buffer resets, a different concept); the continuation body reuses EMPTY_RESPONSE_RECOVERY_PREFIX rather than minting a new marker/locale pair; the rendered warning keeps billed=True while omitting prompt, preamble, tool titles and token counts; and session.md + acp-client.md are updated in the same commit per the same-commit spec rule.
Non-blocking, worth a follow-up:
- The give-up notice can overstate what happened. At
chat_runner.py~10025 theelsesets an unconditional "it was retried and auto-continued automatically", but a productive turn skips rung 1 and — when rung 2 is also skipped (empty_response_auto_continue=false, or_should_suppress_requeue) — lands there with_empty_response_retries == 0, asserting a retry that never ran. Notice-card wording only; the duplication fix itself is unaffected. Branching_empty_msgon_empty_activity.productivewould close it. slot.keyin the new diagnostic warning is not new exposure — the predecessor warning already logged it, and theEmptyTurnActivitypayload is all bools and closed constants.
Not verified: the full suite / mypy / ratchet claims (I ran the targeted files only, per scope) and the production incident logs themselves, though the reproduced TEXT→TOOL_CALL→TOOL_RESULT→COMPLETE(end_turn) shape matches the described symptom.
Problem / Motivation
A dashboard session produced three consecutive
Empty model responsewarnings. Two of them were not empty: the transcript shows an assistant preamble and successfully completed tool calls, and the usage records show real billed inference ending on a cleanend_turn.assistant_textis reset at every tool boundary, so a turn that answers and then calls a tool reaches the terminal chain with an empty final segment. Nothing recorded that visible output had already been flushed —_produced_visible_outputis deliberately narrow (it is only set on paths that reset the buffer mid-turn, and the promise-only guard depends on that meaning). So the empty-response ladder classified a productive turn as "the model returned nothing" and took rung 1, which re-queues the ORIGINAL user message. That re-runs tool calls that already completed — a secondsend_message, a second write, a second PR — and re-derives an answer the user has already read.The third attempt was genuinely empty, and could not be diagnosed at all. Five physically different realities collapsed onto the same branch and the same log line:
end_turn, no content),The warning printed none of the state the runner already held, so the three attempts were indistinguishable in the log even though they had three different owners.
What changed
A productive turn can never be replayed verbatim.
EmptyTurnActivity.productiveis the load-bearing predicate — a flushed visible segment, a dispatched tool call, or thinking. Such a turn skips rung 1 entirely and gets at most one continuation, whose body does not tell the model its completed work produced no output (that wording is itself an invitation to redo the side effect). That continuation consumes the remaining recovery budget, so a second continuation can never follow.A genuinely activity-free empty turn is unchanged — it keeps the bounded replay → continue → give-up ladder, which is the self-heal a real provider-side empty depends on.
The verdict now names its cause. One warning per verdict, emitted after the rung is chosen, carrying a closed cause (
no_terminal_event,synthetic_completion,visible_partial,tool_only,thinking_only,provider_empty,other), the rung, a normalised stop reason, and booleans. An omitted stop reason is deliberately distinct from a cleanend_turn: "the provider said the turn ended and produced nothing" is a model-side event, "the provider never said why it stopped" is a transport-side one, and that is the distinction the incident's third attempt needed and did not have.Two ACP-side silences are closed. The pre-turn drain destroys frames from an abandoned turn — possibly including that turn's terminal — and said nothing; it now reports how many, once per turn. And a prompt stream that exhausts cleanly without ever yielding a terminal completion now warns, which is the one state the dashboard structurally cannot tell apart from a model that answered with nothing. Only clean exhaustion is reported, so an ordinary consumer close or cancellation stays silent.
Privacy
Every diagnostic value is a bool or a member of a closed set. No prompt, no response text, no thinking, no tool arguments or results, no paths, no identities, no token counts and no costs — billing is a bool (
billed), which is exactly the fact needed to separate "the provider ran and charged for this turn" from "it never ran". Frame counts stay in the drain's own log line and carry no sizes, because a size leaks response length.Testing
TestProductiveTurnNeverReplaysVerbatim— the incident's own shape (TEXT → TOOL_CALL → TOOL_RESULT → COMPLETE(end_turn)) must not re-queue the prompt, must queue exactly one continuation, and the completed tool must be dispatched once. Plus tool-only, thinking-only, and the scoping test that a genuinely activity-free turn keeps rung 1.TestEmptyTurnDiagnostics— cause and rung vocabularies asserted from the classifier's own inputs (the ranking between overlapping causes is what regresses silently); provider-empty vs no-terminal reported distinctly; an omitted stop reason not laundered intoprovider_empty; and a privacy test that drives a turn carrying secret-marked text, a secret-marked tool title and real billing amounts, then asserts none of them appear in the rendered line whilebilled=Truedoes.Trueturns exactly the three productive cases red (the ORIGINAL user message was re-queued after a turn that already ran a tool) while the provider-empty case stays green.test_subagent_delivery_ttl_anchor.py's consumption-signal guard is re-anchored on the rung marker rather than the branch condition, because that condition now carries the guard and is reformatted whenever it grows a term.Validation
Rebased onto current
main; the three affected files are 1,097 passed, andmypy --platform linuxis clean across 1,288 files. Black ratchet, subprocess-encoding ratchet, isort, flake8, docs-lint and the brand gate all pass.session_handle.pystays grandfathered in the black baseline — the added hunks are black-clean, and formatting the file wholesale would rewrite 33 pre-existing regions.Correcting an earlier claim in this PR's first revision: the first push attributed a set of local shard failures to the environment. That was wrong for the ones that mattered.
test/test_slot_close_recreation_race.pywas failing because the branch was based on amainthat predated #8536, #8549 and #8583 — the three commits that widened the slot-race request double'scan_read_bodysurface — and #5697, which serializes slot model switches under the slot lock. On the stale base that file timed out; on currentmainit is 42 passed in 4.8s, with no change to this PR's code. The rebase is the fix, and no source change was needed to obtain it.test/test_transcribe.pystill cannot be collected in the local CI-parity venv (imageio_ffmpegabsent). That one is genuinely unrelated — this branch touches no transcribe, voice or STT code.Pattern harvest
Rule candidate: semgrep
Pattern: a boolean "did this turn produce output" flag consulted on a recovery or retry path that can re-send a side-effecting request
Defect class: a predicate whose narrow, documented meaning is correct for its original caller, reused by a later branch that needs a broader question answered — here "did this turn produce visible output" answered by a flag that only means "was the buffer reset mid-turn". The second caller acts on a false negative, and because its action is a replay, the cost is duplicated side effects rather than a merely wrong message. The two callers are 4,000 lines apart, so neither reads like a misuse locally.
Retired by: the activity snapshot is assembled from state the runner already held (terminal seen, synthetic, text, flushed, tools, thinking, billing) and the recovery decision reads
productive, so a future branch asking the same question gets an answer scoped to it rather than borrowing one. The mutation check pins that the guard is load-bearing, and the cause enum makes a misclassification visible in the log instead of silent.