fix(acp): replay loaded session history#2363
Conversation
| case Notification(): | ||
| await self._send_notification(msg) | ||
| replayed_updates += 1 |
There was a problem hiding this comment.
🔴 AssertionError when replaying Notification after TurnEnd in wire history
In replay_history, the Notification case (line 356-358) calls _send_notification → _send_text → _content_run_id("message") which asserts self._turn_state is not None (src/kimi_cli/acp/session.py:438). However, after a TurnEnd is processed during replay (line 325-329), self._turn_state is set to None. Notifications can appear after TurnEnd in the wire file because run_soul() at src/kimi_cli/soul/__init__.py:239 performs a final _deliver_notifications_to_wire_once() flush after the soul task finishes (i.e., after TurnEnd has been emitted) but before the wire shuts down. This means any session with late-flushed background-task notifications will crash with AssertionError when loaded via session/load.
Trace through the crash path
- Wire file contains:
... TurnEnd ... Notification(notification flushed after turn) replay_historyprocessesTurnEnd→ setsself._turn_state = None(line 329)replay_historyprocessesNotification(line 356) → calls_send_notification_send_notification→_send_text→_content_run_id("message")→assert self._turn_state is not None→ AssertionError
Prompt for agents
In replay_history(), the Notification case at lines 356-358 does not guard against self._turn_state being None. When a Notification appears in the wire file after TurnEnd (which happens due to the final notification flush in run_soul), self._turn_state is None and _send_notification -> _send_text -> _content_run_id will hit an assertion error.
The fix should either:
1. Guard the Notification case with a check like `if self._turn_state is not None`, similar to how ToolCallPart and ToolResult are guarded (lines 349, 353). If turn_state is None, either skip the notification or create a temporary turn state via _begin_replay_turn() before sending.
2. Or make _send_text / _content_run_id handle the case where _turn_state is None gracefully (e.g., generate a one-off message_id).
Option 1 is simpler and consistent with the existing pattern for ToolCallPart/ToolResult. The same concern technically applies to ThinkPart and TextPart cases (lines 333-338), though those are far less likely to appear outside a turn.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e25b1d753
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case Notification(): | ||
| await self._send_notification(msg) |
There was a problem hiding this comment.
Guard replayed notifications when no turn is active
replay_history() can call _send_notification() after a TurnEnd (or before any TurnBegin) because wire logs may contain Notification records emitted outside prompt turns (e.g., background-task notifications flushed by the runtime). In that state self._turn_state is None, so _send_notification() -> _send_text() -> _content_run_id() hits the assert and aborts session/load for affected sessions instead of replaying history.
Useful? React with 👍 / 👎.
f86a8d4 to
bea352f
Compare
bea352f to
9030439
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9030439624
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| case Notification(): | ||
| await self._send_notification(msg) | ||
| replayed_updates += 1 |
There was a problem hiding this comment.
Reset content run around replayed notifications
In replay_history(), notifications are replayed via _send_notification() without clearing the current content run first, unlike the live prompt() path which resets before and after notifications. If a Notification is persisted between assistant text chunks in the same turn, replay reuses the same message_id for notification and adjacent assistant chunks, so clients that group by message_id will merge system notification text into the assistant message and lose original message boundaries.
Useful? React with 👍 / 👎.
9030439 to
86d60a1
Compare
Summary
This PR builds on #2359 and includes all current commits from that PR plus the session-load follow-up:
messageIdvalues, and terminal auth login invocation support.session/loadreplay restored history, includingmessageIdfields on replayed user, assistant, and thought chunks, and expose restored session metadata.I can rebase this PR after #2359 lands so it contains only the session-load replay change. Alternatively, if maintainers prefer to take both fixes together, this PR can be merged as the combined ACP fix and #2359 can be closed.
Changes
LoadSessionResponsewith initial modes and models fromsession/load.sessionCapabilities._meta.kimi.sessionHistoryReplay = trueso clients do not have to infer replay behavior fromloadSessionalone.wire.jsonlrecords as ACPsession/updatenotifications on load.messageIdon restored user, assistant, and thought chunks using the message-id run tracking from fix(acp): assign message ids to streamed content #2359.SessionInfoUpdatetitle/updatedAt updates after prompt completion, and duringsession/load/session/resume.session/loadandsession/resumeunder_meta.kimi.session.KimiCLI.run()turns to the session wire log so future loads have a replay source.messageId, advertised replay capability, and session title metadata with tests.Validation
uv run pytest tests/acp tests/core/test_notifications.py -quv run ruff check src/kimi_cli/acp/session.py src/kimi_cli/acp/server.py src/kimi_cli/app.py tests/acp/test_protocol_v1.py tests/acp/test_session_notifications.py tests/acp/test_server_initialize.py tests/ui_and_conv/test_acp_server_auth.pyuv run pyright src/kimi_cli/acp/session.py src/kimi_cli/acp/server.py src/kimi_cli/app.py tests/acp/test_protocol_v1.py tests/acp/test_session_notifications.py tests/acp/test_server_initialize.py tests/ui_and_conv/test_acp_server_auth.pygit diff --check