Skip to content

feat(acp): support permission mode switching#2364

Open
huntharo wants to merge 3 commits into
MoonshotAI:mainfrom
huntharo:fix/acp-permission-mode-switching
Open

feat(acp): support permission mode switching#2364
huntharo wants to merge 3 commits into
MoonshotAI:mainfrom
huntharo:fix/acp-permission-mode-switching

Conversation

@huntharo
Copy link
Copy Markdown

@huntharo huntharo commented May 24, 2026

Related Issue

Resolve #1414

Note that this stacks on top of #2363 - Each PR should be reviewed / merged in order. This is not a 700 line PR trying to change 3 things 😂

Description

I added protocol-level ACP permission mode switching for Kimi sessions.

This PR advertises default and yolo ACP session modes from session/new, session/load, and session/resume, and implements session/set_mode so ACP clients can switch Kimi's persisted yolo approval state without restarting the session. When switching into yolo, Kimi also resolves currently pending approval requests so a permission dialog does not stay stuck after the mode change. The ACP approval bridge now races the client permission request with local request resolution so external mode changes can unblock an in-flight approval.

I checked the prior open context before implementing this: issue #1414 requested switching directly into yolo mode from permission handling, and PR #1525 attempted that by adding an extra approval-dialog option. This implementation keeps the behavior at the ACP session-mode protocol layer instead.

This branch is stacked on the existing ACP message-id and session-load history commits, with this PR still targeting main.

Verification

  • uv run ruff check src/kimi_cli/acp/server.py src/kimi_cli/acp/session.py tests/acp/test_protocol_v1.py tests/acp/test_server_permission_modes.py
  • uv run pytest tests/acp
  • uv run pytest tests/acp/test_protocol_v1.py tests/acp/test_server_permission_modes.py
  • ACP smoke test by spawning kimi acp through the ACP SDK, creating a session, switching default -> yolo -> default, checking resumed mode state, and confirming an invalid mode is rejected: SMOKE_OK root=/tmp/kimi-acp-mode-smoke-qcJqmy

Checklist

  • I have read the CONTRIBUTING document.
  • I have linked the related issue, if any.
  • I have added tests that prove my fix is effective or that my feature works.
  • I have run make gen-changelog to update the changelog.
  • I have run make gen-docs to update the user documentation.

Open in Devin Review

Copy link
Copy Markdown
Contributor

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

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 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +333 to +358
case ThinkPart(think=think):
await self._send_thinking(think)
replayed_updates += 1
case TextPart(text=text):
await self._send_text(text)
replayed_updates += 1
case ContentPart():
logger.warning("Unsupported replay content part: {part}", part=msg)
await self._send_text(f"[{msg.__class__.__name__}]")
replayed_updates += 1
case ToolCall():
if turn_token is None:
turn_token = self._begin_replay_turn()
await self._send_tool_call(msg)
replayed_updates += 1
case ToolCallPart():
if self._turn_state is not None:
await self._send_tool_call_part(msg)
replayed_updates += 1
case ToolResult():
if self._turn_state is not None:
await self._send_tool_result(msg)
replayed_updates += 1
case Notification():
await self._send_notification(msg)
replayed_updates += 1
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.

🔴 AssertionError in replay_history when Notification/content appears outside a turn

In replay_history, after a TurnEnd or StepInterrupted message, self._turn_state is set to None at src/kimi_cli/acp/session.py:329. If a Notification record follows in the wire file, the call chain _send_notification_send_text_content_run_id (line 438) hits assert self._turn_state is not None and crashes with AssertionError.

Notifications from background tasks are pumped into the wire file asynchronously via _pump_notifications_to_wire (src/kimi_cli/soul/__init__.py:279) and can legitimately appear at any position — including after TurnEnd or before the first TurnBegin. The same crash applies to ThinkPart, TextPart, and ContentPart records that appear outside a turn boundary during replay. Unlike ToolCall and StepBegin (which have if turn_token is None: turn_token = self._begin_replay_turn() guards), these message types have no such protection.

Prompt for agents
In replay_history(), the ThinkPart, TextPart, ContentPart, and Notification handlers all call _send_thinking/_send_text which invoke _content_run_id, which asserts self._turn_state is not None. But self._turn_state can be None after TurnEnd/StepInterrupted sets it to None, or before the first TurnBegin.

The fix should mirror what ToolCall and StepBegin already do: check if turn_token is None and call self._begin_replay_turn() to create a new _TurnState before attempting to send. For example, add a guard like:

    if turn_token is None:
        turn_token = self._begin_replay_turn()

before each of the ThinkPart, TextPart, ContentPart, and Notification cases (lines 333-358 in session.py). This ensures _turn_state is always set when _content_run_id is called during replay.

Affected file: src/kimi_cli/acp/session.py, method replay_history(), lines 333-358.
Open in Devin Review

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

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 73f6534022

ℹ️ 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".

Comment on lines +646 to +649
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid cancelling shared approval future in permission race

Cancelling every pending task here also cancels request_task, which is awaiting ApprovalRequest.wait() on the same future consumed by the approval bridge in KimiCLI.run. In the normal path where request_permission returns first, this cancellation marks the shared request future as cancelled, so the later request.resolve(...) calls in this function become no-ops and the bridge never resolves approval_runtime; tool calls that requested approval can then hang indefinitely waiting for a response. This regression is triggered whenever an ACP client responds to a permission dialog before any external mode switch resolves the request.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

1 participant