Skip to content

feat(hooks): add MessageDisplay hook for mid-turn streaming#2512

Open
yanchenko wants to merge 1 commit into
MoonshotAI:mainfrom
yanchenko:feat/message-display-hook
Open

feat(hooks): add MessageDisplay hook for mid-turn streaming#2512
yanchenko wants to merge 1 commit into
MoonshotAI:mainfrom
yanchenko:feat/message-display-hook

Conversation

@yanchenko

@yanchenko yanchenko commented Jul 19, 2026

Copy link
Copy Markdown

Closes #2511.

Summary

Adds a fire-and-forget MessageDisplay hook event that fires repeatedly as the assistant reply streams — before Stop, which fires once at turn end. Modeled on the same feature in Qwen Code (QwenLM/qwen-code#6488), adapted to kimi-cli's hook engine.

Design

  • Payload: message_id (stable per streamed message / model call), displayed_text (cumulative, never a delta), is_final, on top of the common base fields (session_id, cwd, hook_event_name).
  • Debounce: pure step_message_display accumulator (src/kimi_cli/hooks/message_display.py) — leading-edge ~200ms per chunk, no timer; the unconditional final flush delivers the reply's tail.
  • Delivery (MessageDisplayDispatcher): at most one mid-stream execution in flight; newer cumulative payloads replace the queued one (lossless). is_final is dispatched immediately, alongside any stale in-flight execution, and the turn waits for it bounded by a shared 5s drain budget. Suppressed on cancellation and on textless (tool-call-only) messages.
  • Wiring: single choke point in KimiSoul._step wrapping on_message_part — every surface (shell, print/headless, ACP, wire, web) shares it. One dispatcher per model call, spanning the retry wrapper so retried attempts keep one message_id (matching how the UI merges partial text). is_final lands before the Stop hook. Only TextPart counts as displayed; thinking and tool parts are excluded.
  • Fire-and-forget: hook output and exit codes are ignored, and no dispatcher is created when no hooks are configured for the event, so the default path is untouched.

Tests

  • tests/hooks/test_message_display.py — pure debounce table plus dispatcher semantics: coalescing to newest pending, final-supersede alongside stale in-flight, idempotent finish(), abort suppression, drain timeout, delivery-failure swallow (18 tests).
  • tests/core/test_kimisoul_message_display.py — soul-level with scripted providers: final firing with cumulative text before Stop, thinking parts excluded, no-op without configured hooks, cancellation suppresses is_final (4 tests).
  • Updated the event-count assertion and the wire-protocol supported_events lists.

Full suite: pytest tests → 2809 passed. The 65 failures are pre-existing Windows/POSIX environment issues (fcntl/pty imports, missing python3, tab-in-filename, editor/symlink tests) — verified byte-identical on unmodified main. ruff check/ruff format clean; pyright shows only the 6 pre-existing pty-helper errors also present on main.

Docs

docs/{en,zh}/customization/hooks.md: new event table row plus a MessageDisplay section documenting the full delivery-semantics contract.


Open in Devin Review

Fires repeatedly as the assistant reply streams, before Stop (which only
fires once at the end of the turn). Fire-and-forget, cumulative text
payload, debounced (~200ms) except for the unconditional final firing.
Fires from the single streaming path in KimiSoul._step shared by the
shell, print, ACP, wire, and web surfaces.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

View 1 additional finding in Devin Review.

Open in Devin Review

Comment on lines +1216 to +1221
def _on_message_part(part: StreamedMessagePart) -> None:
wire_send(part)
# Only final-answer text counts as displayed; thinking and tool
# parts are different part types and never feed the accumulator.
if message_display is not None and isinstance(part, TextPart):
message_display.add_chunk(part.text)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Retried assistant replies send duplicated, garbled text to streaming hooks

The reply text handed to hook scripts keeps accumulating (add_chunk at src/kimi_cli/soul/kimisoul.py:1221) across a retry instead of being cleared when an attempt fails, so a retried reply arrives with the failed attempt's leftover partial text stuck in front of it.
Impact: When a step is retried after having already streamed some text, hook scripts receive corrupted, duplicated reply content instead of the real reply.

Accumulator is never reset across retry attempts

The dispatcher is constructed once per _step at src/kimi_cli/soul/kimisoul.py:1208-1214, before the tenacity retry wrapper _kosong_step_with_retry. kosong.generate streams every TextPart delta to on_message_part as it arrives (packages/kosong/src/kosong/_generate.py:61-64), so a retryable failure (e.g. APIConnectionError/APITimeoutError) that occurs after partial text has streamed leaves that partial text folded into MessageDisplayState.displayed_text. On the next attempt the full reply is streamed and appended onto that stale prefix, so the mid-stream and final (is_final: true) payloads carry attempt1_partial + attempt2_full.

The comment at src/kimi_cli/soul/kimisoul.py:1206-1207 justifies spanning the retry wrapper by claiming it matches "how the UI merges the attempts' partial text", but the shell UI actually discards the failed attempt's partial content on StepRetry via discard_retry_attempt (src/kimi_cli/ui/shell/visualize/_live_view.py:432-434,547), setting self._current_content_block = None. So the hook displayed_text diverges from what the UI renders, and the displayed_text cumulative-text contract is violated (text can appear duplicated / non-monotonic).

A fix would reset the dispatcher's accumulator at the start of each attempt (e.g. on StepRetry, mirroring discard_retry_attempt) while keeping the same message_id.

Prompt for agents
The MessageDisplayDispatcher is created once per _step (kimisoul.py:1208-1214), before the tenacity retry wrapper, and its cumulative text accumulator (MessageDisplayState.displayed_text) is never reset between retry attempts. Because kosong streams partial TextPart deltas to on_message_part before a retryable error is raised, a retried step accumulates attempt1's partial text and then appends attempt2's full text, producing garbled/duplicated displayed_text in both mid-stream and is_final hook payloads. The shell UI (src/kimi_cli/ui/shell/visualize/_live_view.py, discard_retry_attempt) discards the failed attempt's partial content on StepRetry, so the hook text diverges from the UI and the comment's parity claim (kimisoul.py:1206-1207) is incorrect. Consider adding a method on MessageDisplayDispatcher to reset the debounce accumulator (displayed_text/last_flushed_text) to empty while keeping message_id, and invoking it when an attempt is retried (e.g. via the retry before_sleep callback _before_step_retry_sleep, which already fires on each retry). Ensure any in-flight mid-stream state remains consistent after reset.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(hooks): mid-turn streaming hook (MessageDisplay) for live reply consumers

1 participant