fix(dashboard): deliver every chat_message through one identity-carrying door - #7981
Conversation
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca] Backend companion to #5982, per the maintainer ruling on that PR's First Principles escalation: this PR fixes the dominant duplicate producer (mid-less Pre-push review: both model-pinned local lanes (GPT Credit: the root-cause investigation that identified the mid-less broadcast producer grew out of isotope14's work on #5982. |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound root-cause fix, but the helper's two doors still emit different frames — the exact divergence axis this PR exists to kill. Watch
Suggestions
[DESIGN-REVIEWED] a37acb9 |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All evidence gathered and counted. Final assessment: First-Principles-Verdict: CONCERNS The fix is cause-level with zero unfixed siblings, but the helper's What this change shipsIntent: stop the dashboard rendering the same chat reply as multiple bubbles (#5981) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] a37acb9 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsAll three candidates are self-admittedly latent/unreachable, and I've confirmed each dies under falsification:
No grounded Step 2 finding: the compaction path retains No findings. [OPUS-REVIEWED] a37acb9 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
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: |
c4f5b5a to
aa6ed01
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]
|
UX Review (Fable 5) — ⏭️ skippedRevision |
…ing door
A family of call sites appended a row to the chat window and then hand-built a
second broadcast_ws("chat_message", ...) frame for it. slot.append already
delivers one live frame carrying the row's minted meta.mid (via _on_message ->
_broadcast_chat_message) whenever no HTTP stream reader is active, so the
manual frame shipped the SAME row a second time -- and carried no meta.mid, the
one field the client's redelivery guard (isRedeliveredMessage) keys on. The
guard declines mid-less frames rather than guessing, so each extra copy
rendered as a new bubble: one duplicate per emitted segment of a multi-step
channel turn fits the "same reply rendered 5 times" report in #5981.
New helper append_and_surface (state.py) is the one door: append (identity
minted, single delivery), plus a manual frame ONLY when slot._has_reader
suppresses append's own callback -- and that frame carries the row's ts + meta
(mid included), so a client seeing a row through two doors recognises "this
row again" instead of duplicating. Converted sites:
- chat_orchestrator.py: cancelled-latch stop message, stage-complete message,
stage summary, plan-cancelled message (assistant double-emits); the Go/Go All
label (a user row append skips by default -- now broadcast_user=True, one
mid-carrying delivery instead of a sole mid-less manual frame)
- chat_handlers.py: orchestration-stopped message
- chat_runner.py: conversation-cleared confirmation (slot_clear reordered
BEFORE the append: the wipe must precede the confirmation on every path or
it erases the row it announces)
- chat_utils.py: the compaction-notice chokepoint (kind=compaction preserved
on frame and meta)
- workflow_inject.py: workflow-result injection (window_mid still read off the
append for the durable copy)
- handlers/files.py: the file-card site that pioneered the conditional
pattern -- its reader frame now carries ts + meta.mid too
- slack/handler.py: the Slack->dashboard user mirror (broadcast_user=True)
- crew_chat.py: keeps its deliberate broadcast=False + manual frame, but the
frame now ships the APPENDED row's meta (mid included) instead of the
pre-append dict that never had the id
The persistence half of #5981 (channel rows saved without their mid, re-minted
on every rebuild) was fixed separately in #7646; with both halves in place the
remaining frontend sweep in #5982 catches any residual same-mid duplicate.
Tests: test_midless_broadcast_dedup.py pins the helper contract (single
delivery without a reader; identity-carrying frame with one; user-row
broadcast_user semantics; extra fields) and the compaction site end-to-end
(red-before proven: the old site double-delivers, assert 2 == 1). Existing
tests that pinned the old always-broadcast behaviour re-pinned to the new
contract; test doubles updated to mirror the real append contract (return the
row, mint a mid, model _on_message/_has_reader).
Credit: root-cause investigation of the mid-less double-broadcast producer by
isotope14's PR #5982 review cycle.
Fixes #5981
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
aa6ed01 to
a37acb9
Compare
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]
|
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]
|
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca]
|
|
🤖 Kiro Crew [operator: NicholasRBowers#a942f9ca] Review-ready at
Not merging — merge timing stays with maintainers. This PR carries the |
bolichen97
left a comment
There was a problem hiding this comment.
Approving on the strength of a full readiness audit of every open PR against main, not a
line-by-line reading of this diff — recording that plainly so the next reader knows what this
stamp does and does not cover.
Verified against this exact head SHA:
readiness: passedpresent, andPR Readiness— the one required status context onmain
(rulesetprotected-branches) — issuccesson this head.- No check run on this head is
failure,cancelled,timed_outor still in flight. Skipped
jobs are path-filtered conditionals, none of them required. mergeable: true, and the head is not far enough behindmainfor its green CI to describe a
base that no longer exists.- No surviving reviewer
CHANGES_REQUESTED: any such review is on an older commit and therefore
already dismissed bydismiss_stale_reviews_on_push. - Every issue comment, inline review comment and review thread was read and classified. Nothing
left is an unresolved human change request — the remainder is bot review-lane output, resolved
or outdated threads, explicitly non-blocking suggestions, and author status notes.
Auto-merge (squash) is armed, so this lands once every other ruleset requirement is met.
Design Review on e720534 accepted the fix but flagged the remaining risk: the frame was still built twice — once in the WS arm, once in the SSE arm — held identical only by comments, spec prose and one-sided tests. That duplication is exactly how #8045 survived #7981, so leaving it in place while arguing in the Pattern harvest that the class is real would be inconsistent. Extract `chat_message_frame(note)` into state.py's shared-helpers section, beside `parse_cls_meta` (the precedent the reviewer named — a cross-consumer helper that already exists to keep two readers agreeing). Both doors now call it, so a field added to the frame cannot reach one transport and miss the other. `Any` is no longer needed in updates.py — the annotation moved with the dict. Adds `test_the_sse_frame_is_byte_identical_to_the_websocket_frame`: builds one note, drives it through the real SSE handler, and asserts the emitted payload equals `chat_message_frame` for the same note. If either door is re-inlined and drifts, this fails. Verified: 77 passed in test_dashboard_updates_coverage.py, plus 7 in test_midless_broadcast_dedup.py and 99 across test_dashboard_state_ws.py / _close_characterization / _slots_broadcast_coalesce / _outbox_notify_broadcast (state.py is on the broadcast path, so those are in scope). mypy clean (1280 files), flake8 + isort clean, black ratchet passes — both source files are black-clean and neither is baselined.
…elay `DashboardState._broadcast()` feeds ONE producer note to TWO doors. The WebSocket arm carried `cls`/`meta`; the SSE arm in `handlers/updates.py` rebuilt a four-field subset (`slot`/`role`/`content`/`ts`) and dropped both. `meta.mid` is the per-row delivery identity `_ChatSlot.append` stamps, and it is what a client dedups on (`isRedeliveredMessage` in `chatSlice.ts` -- "a frame with NO `mid` is never deduped"). So the SSE door published frames no consumer can recognise as a redelivery. It is the same mid-less door #7981 closed; that PR's changed-file set never included this serialiser, which is why #8045 was filed as the remaining one. Scope, stated honestly: `/api/stream` has no in-repo frontend consumer today (the SPA reads `/api/ws`), so this is contract integrity rather than a live rendering symptom -- it does not fix visible duplicate bubbles. The endpoint is still served, documented, and proxy-allowlisted (`_PROXY_ALLOWED_PREFIXES` forwards a peer's `api/stream` for the remote-crew view). ONE serialiser, and an explicit authorization decision per transport. `chat_message_frame(note, *, include_metadata)` lives in state.py's shared-helpers section beside `parse_cls_meta`. Both doors call it, so a field added to the frame cannot reach one transport and miss the other -- the duplication that let #8045 survive #7981. `include_metadata` is REQUIRED and names a property of the transport, not a preference. The WS arm passes True because it filters per socket downstream (`_send_ws_all` -> `_ws_client_allowed`, deny-by-default event scope -> `_serialize_for_client`). `api_stream` passes `request["is_dashboard_user"]` only: `_broadcast()` fans the raw note to every SSE queue with NO per-app filtering, and `meta` carries `tool_input`, a live `oauth_url` and `approval_id`, so an unconditional pass would expose it to any app token granted that route whatever its `slots:*` scope. state.py already documents this exact class on this exact endpoint (GPT #6789, public-repo status leaked onto `/api/stream`); the established remedy is to keep enrichment on the door that filters. The auth read is the middleware's POSITIVE signal, taken once per connection and defaulting to False so a middleware-bypassing path withholds rather than publishes. An app token's frame is byte-for-byte the pre-existing four-field projection, so this withholds new metadata rather than newly restricting anyone. `cls`/`meta` stay conditional in both directions when included: absent metadata stays absent rather than arriving as `null`/`{}` keys a consumer must special-case. Four tests in test/test_dashboard_updates_coverage.py: - `test_the_sse_chat_message_frame_carries_the_rows_identity` -- the defect repro; fails `KeyError: 'meta'` against the unfixed serialiser. - `test_a_chat_message_without_meta_gains_no_empty_keys` -- no invented empty keys. Passes either way by design; pins the conditional, not the bug. - `test_an_app_token_stream_never_receives_row_metadata` -- the scope gate; verified by removing the gate, which puts `tool_input: "secret"` on the wire. - `test_neither_chat_message_door_builds_the_frame_by_hand` -- source-level guard that both doors delegate to the shared serialiser. Asserted on source because a payload comparison that calls the helper directly still passes after a door is re-inlined. Scoped to each door's own `chat_message` branch: the helper is DEFINED in state.py, so a whole-file scan reads its body as a hand-built frame and its `def` line satisfies a naive call check. Verified silent on baseline and firing on both re-inlining directions. `_request_with_queue` wires `is_dashboard_user` to a real mapping, because a bare MagicMock returns a truthy `.get()` and the app-token assertion would otherwise pass on mock truthiness. Spec: docs/system-specs/modules/learn-cron-dashboard.md's `_broadcast()` clause now states the one-serialiser rule AND the transport asymmetry, per AGENTS.md's same-commit rule for the dashboard-handlers row. Known-divergent siblings left to #8044: `refresh` (WS sends `kinds` as a list, SSE the raw comma-string) and `update_progress`/`artifact_update`/ `session_summary` (typed WS envelopes vs the raw-note SSE fallback). Verified: 78 in test_dashboard_updates_coverage.py; 106 across test_midless_broadcast_dedup, test_dashboard_state_ws, test_dashboard_state_close_characterization, test_slots_broadcast_coalesce and test_outbox_notify_broadcast (state.py is on the broadcast path). mypy clean (1280 files), flake8 + isort clean, black ratchet passes with both source files black-clean and unbaselined. Fixes #8045
…elay (#8199) `DashboardState._broadcast()` feeds ONE producer note to TWO doors. The WebSocket arm carried `cls`/`meta`; the SSE arm in `handlers/updates.py` rebuilt a four-field subset (`slot`/`role`/`content`/`ts`) and dropped both. `meta.mid` is the per-row delivery identity `_ChatSlot.append` stamps, and it is what a client dedups on (`isRedeliveredMessage` in `chatSlice.ts` -- "a frame with NO `mid` is never deduped"). So the SSE door published frames no consumer can recognise as a redelivery. It is the same mid-less door #7981 closed; that PR's changed-file set never included this serialiser, which is why #8045 was filed as the remaining one. Scope, stated honestly: `/api/stream` has no in-repo frontend consumer today (the SPA reads `/api/ws`), so this is contract integrity rather than a live rendering symptom -- it does not fix visible duplicate bubbles. The endpoint is still served, documented, and proxy-allowlisted (`_PROXY_ALLOWED_PREFIXES` forwards a peer's `api/stream` for the remote-crew view). ONE serialiser, and an explicit authorization decision per transport. `chat_message_frame(note, *, include_metadata)` lives in state.py's shared-helpers section beside `parse_cls_meta`. Both doors call it, so a field added to the frame cannot reach one transport and miss the other -- the duplication that let #8045 survive #7981. `include_metadata` is REQUIRED and names a property of the transport, not a preference. The WS arm passes True because it filters per socket downstream (`_send_ws_all` -> `_ws_client_allowed`, deny-by-default event scope -> `_serialize_for_client`). `api_stream` passes `request["is_dashboard_user"]` only: `_broadcast()` fans the raw note to every SSE queue with NO per-app filtering, and `meta` carries `tool_input`, a live `oauth_url` and `approval_id`, so an unconditional pass would expose it to any app token granted that route whatever its `slots:*` scope. state.py already documents this exact class on this exact endpoint (GPT #6789, public-repo status leaked onto `/api/stream`); the established remedy is to keep enrichment on the door that filters. The auth read is the middleware's POSITIVE signal, taken once per connection and defaulting to False so a middleware-bypassing path withholds rather than publishes. An app token's frame is byte-for-byte the pre-existing four-field projection, so this withholds new metadata rather than newly restricting anyone. `cls`/`meta` stay conditional in both directions when included: absent metadata stays absent rather than arriving as `null`/`{}` keys a consumer must special-case. Four tests in test/test_dashboard_updates_coverage.py: - `test_the_sse_chat_message_frame_carries_the_rows_identity` -- the defect repro; fails `KeyError: 'meta'` against the unfixed serialiser. - `test_a_chat_message_without_meta_gains_no_empty_keys` -- no invented empty keys. Passes either way by design; pins the conditional, not the bug. - `test_an_app_token_stream_never_receives_row_metadata` -- the scope gate; verified by removing the gate, which puts `tool_input: "secret"` on the wire. - `test_neither_chat_message_door_builds_the_frame_by_hand` -- source-level guard that both doors delegate to the shared serialiser. Asserted on source because a payload comparison that calls the helper directly still passes after a door is re-inlined. Scoped to each door's own `chat_message` branch: the helper is DEFINED in state.py, so a whole-file scan reads its body as a hand-built frame and its `def` line satisfies a naive call check. Verified silent on baseline and firing on both re-inlining directions. `_request_with_queue` wires `is_dashboard_user` to a real mapping, because a bare MagicMock returns a truthy `.get()` and the app-token assertion would otherwise pass on mock truthiness. Spec: docs/system-specs/modules/learn-cron-dashboard.md's `_broadcast()` clause now states the one-serialiser rule AND the transport asymmetry, per AGENTS.md's same-commit rule for the dashboard-handlers row. Known-divergent siblings left to #8044: `refresh` (WS sends `kinds` as a list, SSE the raw comma-string) and `update_progress`/`artifact_update`/ `session_summary` (typed WS envelopes vs the raw-note SSE fallback). Verified: 78 in test_dashboard_updates_coverage.py; 106 across test_midless_broadcast_dedup, test_dashboard_state_ws, test_dashboard_state_close_characterization, test_slots_broadcast_coalesce and test_outbox_notify_broadcast (state.py is on the broadcast path). mypy clean (1280 files), flake8 + isort clean, black ratchet passes with both source files black-clean and unbaselined. Fixes #8045
Problem / Motivation
The dashboard can render the same chat reply multiple times — the reported case (#5981) is a channel (WeChat) reply appearing 5 times in a row. The investigation on the issue traced the dominant producer to backend call sites that append a row to the chat window and then hand-build a second
broadcast_ws("chat_message", ...)frame for the same row, with nometa.midon it.Why it matters
slot.appendalready delivers one live frame carrying the row's mintedmeta.mid(via_on_message→_broadcast_chat_message) whenever no HTTP stream reader is active — so the manual frame ships the same row twice. The client's redelivery guard (isRedeliveredMessage,chatSlice.ts) keys onmeta.midand deliberately declines mid-less frames rather than guessing, so each extra copy renders as a new bubble. One mid-less frame per emitted segment of a multi-step channel turn fits the "5 identical bubbles" report. Every user of the dashboard alongside an active channel session is exposed.What changed (motivation → approach → change)
Symptom: duplicate bubbles → root cause: a family of append-then-manual-broadcast double-emits whose second frame carries no identity → change: one chokepoint,
append_and_surface(src/kiro_crew/dashboard/state.py), that delivers every appended row through exactly one identity-carrying door: append's own mid-carrying delivery when no reader is draining, or a single manual frame carrying the row'sts+meta(mid included) whenslot._has_readersuppresses append's callback — the conditional patternhandlers/files.pyalready pioneered, now with identity on the frame.Complete branch table of every
chat_message-emitting site audited (16 candidates):chat_orchestrator.pycancelled-latch stop msgchat_orchestrator.pystage-complete msgchat_orchestrator.pystage summarychat_orchestrator.pyplan-cancelled msgchat_orchestrator.pyGo/Go All labelbroadcast_user=Truechat_handlers.pyorchestration-stoppedchat_runner.pyconversation-clearedslot_clearreordered BEFORE the append (the wipe must precede the confirmation or it erases the row it announces)chat_utils.pycompaction chokepointkind=compactionpreserved on frame+metaworkflow_inject.pyworkflow resultwindow_midstill read off the append for the durable copyhandlers/files.pyfile cardts+meta.midslack/handler.pySlack→dashboard user mirrorbroadcast_user=True(module-level import:dashboard.statewas already in this module's import chain viachat_utils)crew_chat.pycrew reply forwardbroadcast=False+ manual frame; frame shipped the pre-append meta (no mid)chat_runner.pyMCP-auth update, tool-patch updatechat_message_update(different event)chat_runner.pycompacting statusThe persistence half of #5981 (channel rows saved without their mid, re-minted on every rebuild) was fixed separately in #7646; with both halves in place, the frontend sweep in #5982 catches any residual same-mid duplicate — the three changes are complementary.
Tests
test/test_midless_broadcast_dedup.py(new): pins the helper contract — single delivery with no reader; exactly one identity-carrying frame (meta.mid+ts) with a reader; user-rowbroadcast_usersemantics (channel-typed rows deliver once, locally-typed rows stay optimistic);extrafields (e.g.kind=compaction) riding the reader frame; and the compaction site end-to-end. Red-before proven: with the old chat_utils site restored, the site test failsassert 2 == 1on the double delivery.test_workflows_inject.py,test_workflows_service.py,test_outbox_notify_broadcast.py,test_slack_handler_more_coverage.py,test_handler_link_intercept.py,test_dashboard_chat.py); their slot doubles updated to mirror the realappendcontract (return the row, mint a mid, model_on_message/_has_reader).Manual verification
N/A — unit coverage sufficient: the delivery-count and identity assertions exercise the exact seam the bug lives in, and the changed messages are system strings with no rendering change.
Related Issues
Fixes #5981
Pattern harvest
Rule candidate: semgrep
Pattern: hand-built
broadcast_ws("chat_message", {...})payload withoutmeta— anychat_messageframe that does not carry the appended row'smeta.middefeats the client's redelivery guard and renders duplicates; new code must route throughappend_and_surface.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)