Skip to content

fix(chat): fold every sub-agent wave in a user turn, not just the first - #8323

Open
iamwhatever wants to merge 1 commit into
mainfrom
fix/fold-post-synthesis-fanout-region
Open

fix(chat): fold every sub-agent wave in a user turn, not just the first#8323
iamwhatever wants to merge 1 commit into
mainfrom
fix/fold-post-synthesis-fanout-region

Conversation

@iamwhatever

Copy link
Copy Markdown
Collaborator

Problem / Motivation

A chat turn that fans out sub-agents more than once renders every wave after the
first in full: the per-completion prose the agent writes as each result lands
sits in the transcript immediately above a synthesis turn that restates it. The
user reads the same report twice, at full length.

Reported from a live session judging two large PR stacks — a collapsed
Worked through N steps toggle, the 2 agents finished run card, then a
full-length verdict, then Sub-agents finished · consolidated summary requested
followed by a synthesis saying the same thing again.

Why it matters

Folding that interim region is the whole remedy for post-fan-out duplication
(#7231). Wave 1 got it; waves 2..N never did — and a second wave inside one user
turn is the normal shape for the work this feature was built for, because the
gateway re-arms _pending_synthesis whenever a wave's last agent finishes,
whichever turn spawned it. So the duplication the fold exists to remove came back
for the majority of real fan-out sessions, while looking fixed.

What changed (motivation → approach → change)

Symptom: only the first wave in a user turn folds.

Root cause: the region a synthesis row opens was disqualified wholesale with
regionHasForeign = true. That flag exists to keep unrelated content (a cron
reply, a note) out of a fold, and it was borrowed here to protect one specific
row — the synthesis turn's own answer, which is real user-facing content no later
synthesis restates. Protecting one row by poisoning the whole region meant no
wave after the first could ever fold, and the flag stayed sticky until the next
user prompt.

Change: carve out just the answer instead of the region.

  • isTurnEnd identifies a completed turn's last assistant row by the
    turn_stats meta the runner already stamps there (_attach_turn_stats,
    chat_runner.py). Nothing else in the display layer marks a turn boundary —
    chat_segment is a live wire event, not a property of a persisted row — so
    this is what a reload has to go on.
  • settlePendingSynthesisAnswer() splits an un-flushed synthesis answer off at
    that boundary, flushes it as its own visible (non-interim) turn, and
    re-anchors regionStart behind it. The rows after it belong to the next wave
    and fold normally.
  • It runs at both flush sites that can hold a pending answer: the synthesis
    branch, and the subagent turn-opener — a completion row drained through the
    slot queue flushes the batch there, and skipping it let the next synthesis
    mistake that wave's own reply for the answer boundary and leave the wave
    unfolded.
  • Re-anchoring re-derives regionHasForeign from the batch it retains rather
    than clearing it. Clearing is what lets a cron reply in wave 1 stop
    disqualifying waves 2..N; re-deriving is what keeps a cron reply that landed
    after the answer — and is therefore still inside the new region —
    disqualifying it, instead of folding an unrelated prompt's reply behind the
    fan-out toggle.

Every failure path still degrades toward unfolded: with no locatable turn
boundary (an errored turn, a pre-turn_stats transcript, an answer still
streaming) the region renders exactly as it did before this change rather than
guessing where the answer ended. Hiding an answer behind a toggle that promises a
repeat below is the one outcome that must stay impossible.

Display-only. No message content, no wire shape, and no backend behaviour
changes — the model still receives every result in full.

Tests

website/src/test/groupDisplayItems.test.ts, five new cases:

  • a second wave in the same user turn folds, with round one's answer outside
    it
    — two interim regions, both synthesis answers visible. This is the
    reported bug; it fails on the pre-fix module.
  • a second wave reached through a queue-drained subagent completion opener
    folds
    — the second flush site. Removing the settlePendingSynthesisAnswer()
    call there turns this red, so the call is not decorative.
  • a later wave folds even though a cron reply disqualified an earlier one
    the re-anchor's foreign reset. Dropping that reset turns this red.
  • a cron reply that landed AFTER a synthesis answer stays out of the next
    fold
    — the other half of the same rule: re-derived, not cleared. This one
    fails if the reset is unconditional.
  • no turn boundary anywhere leaves the region unfolded — pins the safe
    degradation, so a future change cannot turn a missing turn_stats into a
    hidden answer.

The pre-existing case asserting a synthesis answer is never folded behind a later
wave's toggle still passes unchanged.

Manual verification

N/A — groupDisplayItems is a pure function over the message list and the
regression is a grouping decision, which the unit tests assert directly. The
reported transcript shape is reproduced verbatim as a test fixture.

Screenshots / video

Why no screenshot: grouping logic only — the fold's rendered appearance
(TurnBlock's interim branch, its toggle and collapsed sections) is untouched;
this change only decides which rows enter that already-shipped fold.

Related Issues

no linked issue: reported directly in chat from a live session, not filed.

Pattern harvest

Pattern: a broad disqualifier flag reused to protect one narrow row, which then
suppresses the feature for every later region in the same scope — and, on the way
out, a reset of that flag that must be re-derived from the retained state rather
than cleared.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

Problem: a user turn that fans out more than once rendered every wave
after the first in full -- the per-completion prose sat in the transcript
beside a synthesis turn that restated it, which is exactly the
duplication the fan-out fold was added to remove.

Root cause: the region a synthesis row opens was marked
`regionHasForeign = true` wholesale, to keep that row's own answer out of
a later wave's fold. That protected the answer by disqualifying the whole
region, so no wave after the first could ever fold, and the flag stayed
sticky until the next user prompt.

Change: carve out just the answer instead of the region.
`settlePendingSynthesisAnswer` splits an un-flushed synthesis answer off
at its turn boundary -- the `turn_stats` meta the runner stamps on a
completed turn's last assistant row -- flushes it as its own visible
turn, and re-anchors the region behind it, so the rows after it (the next
wave's interim work) fold normally. It runs at BOTH flush sites that can
hold a pending answer: the synthesis branch and the `subagent`
turn-opener a queue-drained completion arrives as.

Re-anchoring RE-DERIVES `regionHasForeign` from the batch it retains
rather than clearing it, so a cron reply or note in wave 1 no longer
disqualifies waves 2..N while one that landed after the answer -- and is
therefore still inside the new region -- keeps it disqualified.

With no locatable turn boundary (an errored turn, an older transcript, an
answer still streaming) the region degrades to unfolded -- the previous
behaviour -- rather than guessing where the answer ended. Display-only:
the model still receives every result in full.
@iamwhatever
iamwhatever requested a review from a team September 3, 2026 23:51
@iamwhatever
iamwhatever requested a review from a team as a code owner September 3, 2026 23:51
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of c7a6c9f2127f00918b82b10a7165513af1ed56bc — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Extends the existing fan-out fold to later waves, keeping every synthesis answer visible; all uncertain paths degrade to the familiar unfolded rendering.

No new strings, controls, or visual surfaces — the change removes the duplicated per-completion prose users saw beside each restating synthesis, and every ambiguous boundary (no turn_stats, streaming answer, trailing cron reply) falls back to the pre-fix unfolded view rather than hiding content behind a toggle. The fold pattern and its rendering (TurnBlock.tsx) are unchanged, so nothing new to learn.

[UX-REVIEWED] c7a6c9f

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of c7a6c9f2127f00918b82b10a7165513af1ed56bc — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: PASS

Root-cause fix — replaces the region-wide poison flag with a per-answer carve-out, degrades unfolded on any doubt, each mechanism pinned by a failing-without-it test.

Suggestions

  • turn_stats is now a load-bearing turn-boundary contract, but only the frontend pins it; add a sentence at _attach_turn_stats (chat_runner.py:1501) noting the fold's boundary detection depends on its last-assistant-row placement, so a backend stats refactor doesn't silently regress this exact bug with all backend tests green.

[DESIGN-REVIEWED] c7a6c9f

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of c7a6c9f2127f00918b82b10a7165513af1ed56bc — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

The change checks out against the repository. Verified: _attach_turn_stats (src/kiro_crew/dashboard/chat_runner.py:1493) does stamp turn_stats on the last assistant message of each completed turn and no-ops on errored/empty turns, matching the degradation story; turn_stats is an already-persisted meta the frontend already reads (AssistantMessage.tsx, messageRenderers.tsx), so no new wire surface; the only other boundary predicate, isTurnBoundaryUser (chatSlice.ts:1736), classifies user rows for streaming-burst lifecycle — a different job, not a duplicate. All new symbols are module-private with real internal consumers, every behavior in the diff is declared in the description, and each is pinned by a test.

First-Principles-Verdict: PASS

A reported live-session defect, fixed at its named cause — the borrowed region-poisoning flag — with every behavior declared, tested, and degrading toward visible.

What this change ships

Intent: stop later sub-agent waves in one chat turn from showing their interim prose twice, in full, beside the synthesis that restates it. FIX.

  1. Waves 2..N in a user turn now fold behind the interim toggle — justified (the reported defect)
  2. An earlier wave's synthesis answer is split off as its own visible turn — justified, declared
  3. A cron reply in wave 1 no longer suppresses later waves' folds — justified, declared, tested
  4. A cron reply landing after an answer still keeps the next region unfolded — justified, declared, tested
  5. Missing turn_stats anywhere leaves the region unfolded, exactly as before — justified safe degradation, declared, tested

No undeclared items, no riders, no moves or relabels, no new public surface. Counted: isTurnEnd 1 consumer, settlePendingSynthesisAnswer 2 call sites, both module-private; grepped turnBoundary|end.of.turn across website/src — no existing mechanism does this job; grepped remaining regionHasForeign = true sites — 2, both deliberate (foreign inject, no-boundary degrade), zero unfixed siblings. The deeper cause (no persisted turn-boundary field on rows) is out of this display-only change's scope, and the description says so plainly.

[FIRST-PRINCIPLES-REVIEWED] c7a6c9f

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c7a6c9f2127f00918b82b10a7165513af1ed56bc — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] c7a6c9f

Verdict parsed from the review's SHA-scoped output markers for commit c7a6c9f2127f00918b82b10a7165513af1ed56bc.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c7a6c9f2127f00918b82b10a7165513af1ed56bc: <one-sentence reason>

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of c7a6c9f2127f00918b82b10a7165513af1ed56bc and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c7a6c9f

False positive or not applicable? A repository writer can comment:
/ai-review override gpt c7a6c9f2127f00918b82b10a7165513af1ed56bc: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant