diff --git a/pyproject.toml b/pyproject.toml index e1f71a322..eea789f5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Kimi Code CLI is your next CLI agent." readme = "README.md" requires-python = ">=3.12" dependencies = [ - "agent-client-protocol==0.8.0", + "agent-client-protocol==0.10.0", "aiofiles>=24.0,<26.0", "aiohttp==3.13.3", "typer==0.21.1", diff --git a/src/kimi_cli/acp/server.py b/src/kimi_cli/acp/server.py index 0b6b32538..84b5f4bfa 100644 --- a/src/kimi_cli/acp/server.py +++ b/src/kimi_cli/acp/server.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import sys import time from datetime import datetime from pathlib import Path @@ -33,7 +32,9 @@ def __init__(self) -> None: self.conn: acp.Client | None = None self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {} self.negotiated_version: ACPVersionSpec | None = None - self._auth_methods: list[acp.schema.AuthMethod] = [] + self._auth_methods: list[ + acp.schema.EnvVarAuthMethod | acp.schema.TerminalAuthMethod | acp.schema.AuthMethodAgent + ] = [] def on_connect(self, conn: acp.Client) -> None: logger.info("ACP client connected") @@ -66,32 +67,18 @@ async def initialize( version=getattr(client_info, "version", None), ) - # get command and args of current process for terminal-auth - command = sys.argv[0] - args: list[str] = [] - - # Build terminal auth data for error response - terminal_args = args + ["login"] - # Build and cache auth methods for reuse in AUTH_REQUIRED errors self._auth_methods = [ - acp.schema.AuthMethod( + acp.schema.TerminalAuthMethod( id="login", + type="terminal", name="Login with Kimi account", description=( "Run `kimi login` command in the terminal, " "then follow the instructions to finish login." ), - # Store auth data in field_meta for building AUTH_REQUIRED error - field_meta={ - "terminal-auth": { - "command": command, - "args": terminal_args, - "label": "Kimi Code Login", - "env": {}, - "type": "terminal", - } - }, + args=["login"], + env={}, ), ] @@ -129,20 +116,9 @@ def _check_auth(self) -> None: """Check if Kimi Code authentication is complete. Raise AUTH_REQUIRED if not.""" reason = self._check_token_usable() if reason: - auth_methods_data: list[dict[str, Any]] = [] - for m in self._auth_methods: - if m.field_meta and "terminal-auth" in m.field_meta: - terminal_auth = m.field_meta["terminal-auth"] - auth_methods_data.append( - { - "id": m.id, - "name": m.name, - "description": m.description, - "type": terminal_auth.get("type", "terminal"), - "args": terminal_auth.get("args", []), - "env": terminal_auth.get("env", {}), - } - ) + auth_methods_data = [ + m.model_dump(by_alias=True, exclude_none=True) for m in self._auth_methods + ] logger.warning("Authentication required, {reason}", reason=reason) raise acp.RequestError.auth_required({"authMethods": auth_methods_data}) @@ -390,7 +366,10 @@ async def authenticate(self, method_id: str, **kwargs: Any) -> acp.AuthenticateR raise acp.RequestError.auth_required( { "message": "Please complete login in terminal first", - "authMethods": self._auth_methods, + "authMethods": [ + m.model_dump(by_alias=True, exclude_none=True) + for m in self._auth_methods + ], } ) diff --git a/src/kimi_cli/acp/session.py b/src/kimi_cli/acp/session.py index fa74e801b..7c984db32 100644 --- a/src/kimi_cli/acp/session.py +++ b/src/kimi_cli/acp/session.py @@ -116,8 +116,22 @@ def __init__(self): self.tool_calls: dict[str, _ToolCallState] = {} """Map of tool call ID (LLM-side ID) to tool call state.""" self.last_tool_call: _ToolCallState | None = None + self.content_run_kind: str | None = None + """The active ACP content run kind: `message` or `thought`.""" + self.content_run_message_id: str | None = None + """Stable ACP message ID for the current contiguous content run.""" self.cancel_event = asyncio.Event() + def reset_content_run(self) -> None: + self.content_run_kind = None + self.content_run_message_id = None + + def content_run_id(self, kind: str) -> str: + if self.content_run_kind != kind or self.content_run_message_id is None: + self.content_run_kind = kind + self.content_run_message_id = str(uuid.uuid4()) + return self.content_run_message_id + class ACPSession: def __init__( @@ -161,29 +175,32 @@ async def prompt(self, prompt: list[ACPContentBlock]) -> acp.PromptResponse: async for msg in self._cli.run(user_input, self._turn_state.cancel_event): match msg: case TurnBegin(): - pass + self._reset_content_run() case SteerInput(): - pass + self._reset_content_run() case TurnEnd(): - pass + self._reset_content_run() case StepBegin(): - pass + self._reset_content_run() case StepInterrupted(): + self._reset_content_run() break case StepRetry(): - pass + self._reset_content_run() case CompactionBegin(): - pass + self._reset_content_run() case CompactionEnd(): - pass + self._reset_content_run() case MCPLoadingBegin(): - pass + self._reset_content_run() case MCPLoadingEnd(): - pass + self._reset_content_run() case StatusUpdate(): - pass + self._reset_content_run() case Notification(): + self._reset_content_run() await self._send_notification(msg) + self._reset_content_run() case ThinkPart(think=think): await self._send_thinking(think) case TextPart(text=text): @@ -253,6 +270,14 @@ async def cancel(self) -> None: self._turn_state.cancel_event.set() + def _reset_content_run(self) -> None: + if self._turn_state is not None: + self._turn_state.reset_content_run() + + def _content_run_id(self, kind: str) -> str: + assert self._turn_state is not None + return self._turn_state.content_run_id(kind) + async def _send_thinking(self, think: str): """Send thinking content to client.""" if not self._id or not self._conn: @@ -262,6 +287,7 @@ async def _send_thinking(self, think: str): self._id, acp.schema.AgentThoughtChunk( content=acp.schema.TextContentBlock(type="text", text=think), + message_id=self._content_run_id("thought"), session_update="agent_thought_chunk", ), ) @@ -275,6 +301,7 @@ async def _send_text(self, text: str): session_id=self._id, update=acp.schema.AgentMessageChunk( content=acp.schema.TextContentBlock(type="text", text=text), + message_id=self._content_run_id("message"), session_update="agent_message_chunk", ), ) @@ -292,6 +319,7 @@ async def _send_tool_call(self, tool_call: ToolCall): assert self._turn_state is not None if not self._id or not self._conn: return + self._reset_content_run() # Create and store tool call state state = _ToolCallState(tool_call) @@ -353,6 +381,7 @@ async def _send_tool_result(self, result: ToolResult): assert self._turn_state is not None if not self._id or not self._conn: return + self._reset_content_run() tool_ret = result.return_value diff --git a/src/kimi_cli/cli/__init__.py b/src/kimi_cli/cli/__init__.py index b9aafb7a8..3435811af 100644 --- a/src/kimi_cli/cli/__init__.py +++ b/src/kimi_cli/cli/__init__.py @@ -1015,8 +1015,23 @@ def term( @cli.command() -def acp(): +def acp( + auth_command: Annotated[ + str | None, + typer.Argument( + help="Internal terminal-auth command.", + show_default=False, + hidden=True, + ), + ] = None, +): """Run Kimi Code CLI ACP server.""" + if auth_command == "login": + login(json=False) + return + if auth_command is not None: + raise typer.BadParameter("Unknown ACP auth command") + from kimi_cli.acp import acp_main acp_main() diff --git a/tests/acp/test_cli_auth_command.py b/tests/acp/test_cli_auth_command.py new file mode 100644 index 000000000..19975df12 --- /dev/null +++ b/tests/acp/test_cli_auth_command.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typer.testing import CliRunner + +from kimi_cli.cli import cli + + +def test_acp_login_delegates_to_top_level_login(monkeypatch): + called: list[bool] = [] + + def fake_login(*, json: bool = False) -> None: + called.append(json) + + monkeypatch.setattr("kimi_cli.cli.login", fake_login) + + result = CliRunner().invoke(cli, ["acp", "login"]) + + assert result.exit_code == 0 + assert called == [False] diff --git a/tests/acp/test_server_initialize.py b/tests/acp/test_server_initialize.py index ad20a23e6..1810c7ce3 100644 --- a/tests/acp/test_server_initialize.py +++ b/tests/acp/test_server_initialize.py @@ -2,8 +2,6 @@ from __future__ import annotations -from unittest.mock import patch - import pytest from kimi_cli.acp.server import ACPServer @@ -11,38 +9,17 @@ pytestmark = pytest.mark.asyncio -@pytest.mark.parametrize( - "argv, expected_command, expected_terminal_args", - [ - # Standard entry-point: kimi acp - (["/usr/local/bin/kimi", "acp"], "/usr/local/bin/kimi", ["login"]), - # kimi-code entry-point (JetBrains scenario) - (["/usr/local/bin/kimi-code", "acp"], "/usr/local/bin/kimi-code", ["login"]), - # kimi-cli entry-point - (["/usr/local/bin/kimi-cli", "acp"], "/usr/local/bin/kimi-cli", ["login"]), - # Arbitrary wrapper script - (["/opt/wrapper.sh", "acp"], "/opt/wrapper.sh", ["login"]), - ], - ids=["kimi", "kimi-code", "kimi-cli", "wrapper-script"], -) -async def test_initialize_argv_handling( - argv: list[str], - expected_command: str, - expected_terminal_args: list[str], -): - """initialize() should not crash regardless of sys.argv content.""" +async def test_initialize_advertises_terminal_auth_method(): + """initialize() should advertise terminal auth using the ACP schema.""" server = ACPServer() - with patch("kimi_cli.acp.server.sys") as mock_sys: - mock_sys.argv = argv - resp = await server.initialize(protocol_version=1) + resp = await server.initialize(protocol_version=1) assert resp.protocol_version == 1 assert resp.auth_methods is not None assert len(resp.auth_methods) == 1 auth_method = resp.auth_methods[0] - assert auth_method.field_meta is not None - terminal_auth = auth_method.field_meta["terminal-auth"] - assert terminal_auth["command"] == expected_command - assert terminal_auth["args"] == expected_terminal_args + assert auth_method.type == "terminal" + assert auth_method.args == ["login"] + assert auth_method.env == {} diff --git a/tests/acp/test_session_notifications.py b/tests/acp/test_session_notifications.py index 70b9197d4..fbbfbd5ea 100644 --- a/tests/acp/test_session_notifications.py +++ b/tests/acp/test_session_notifications.py @@ -5,6 +5,7 @@ import acp import pytest +from kosong.tooling import ToolOk, ToolResult from kosong.tooling.empty import EmptyToolset from kimi_cli.acp.session import ACPSession @@ -14,7 +15,7 @@ from kimi_cli.soul.agent import Agent, Runtime from kimi_cli.soul.context import Context from kimi_cli.soul.kimisoul import KimiSoul -from kimi_cli.wire.types import Notification, TextPart, ToolCall, TurnBegin, TurnEnd +from kimi_cli.wire.types import Notification, TextPart, ThinkPart, ToolCall, TurnBegin, TurnEnd class _FakeConn: @@ -46,6 +47,21 @@ async def run(self, _user_input, _cancel_event): yield TurnEnd() +class _FakeStreamingCLI: + async def run(self, _user_input, _cancel_event): + yield TurnBegin(user_input=[TextPart(text="hello")]) + yield ThinkPart(think="thinking") + yield TextPart(text="Hi mom") + yield TextPart(text=", what is for dinner?") + yield ToolCall( + id="call-dinner", + function=ToolCall.FunctionBody(name="ReadFile", arguments='{"path":"menu.txt"}'), + ) + yield ToolResult(tool_call_id="call-dinner", return_value=ToolOk(output="pizza")) + yield TextPart(text="Tell Dad I said hi") + yield TurnEnd() + + @pytest.mark.asyncio async def test_acp_session_surfaces_notification_as_message_chunk() -> None: conn = _FakeConn() @@ -64,6 +80,33 @@ async def test_acp_session_surfaces_notification_as_message_chunk() -> None: assert text_update.content.text == "done" +@pytest.mark.asyncio +async def test_acp_session_assigns_message_ids_to_distinct_content_runs() -> None: + conn = _FakeConn() + session = ACPSession("session-1", _FakeStreamingCLI(), conn) # type: ignore[arg-type] + + response = await session.prompt([acp.text_block("hello")]) + + assert response.stop_reason == "end_turn" + chunks = [ + update + for _, update in conn.updates + if update.session_update in {"agent_thought_chunk", "agent_message_chunk"} + ] + assert [chunk.content.text for chunk in chunks] == [ + "thinking", + "Hi mom", + ", what is for dinner?", + "Tell Dad I said hi", + ] + assert chunks[0].message_id + assert chunks[1].message_id + assert chunks[1].message_id == chunks[2].message_id + assert chunks[3].message_id + assert chunks[3].message_id != chunks[1].message_id + assert chunks[0].message_id not in {chunks[1].message_id, chunks[3].message_id} + + class _BlockingApprovalConn(_FakeConn): def __init__(self) -> None: super().__init__() diff --git a/tests/ui_and_conv/test_acp_server_auth.py b/tests/ui_and_conv/test_acp_server_auth.py index 40a025821..ae7ed4e19 100644 --- a/tests/ui_and_conv/test_acp_server_auth.py +++ b/tests/ui_and_conv/test_acp_server_auth.py @@ -13,17 +13,13 @@ def server() -> ACPServer: """Create an ACPServer instance with mocked auth methods.""" s = ACPServer() s._auth_methods = [ - acp.schema.AuthMethod( + acp.schema.TerminalAuthMethod( id="login", + type="terminal", name="Test Login", description="Test description", - field_meta={ - "terminal-auth": { - "type": "terminal", - "args": ["kimi", "login"], - "env": {}, - } - }, + args=["login"], + env={}, ) ] return s diff --git a/uv.lock b/uv.lock index 9d1b6ed7c..384eeddef 100644 --- a/uv.lock +++ b/uv.lock @@ -17,14 +17,14 @@ members = [ [[package]] name = "agent-client-protocol" -version = "0.8.0" +version = "0.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/a4/26698e0186933b4ab6e814626c99ee52b5522d039b5c94c983ecb3a66eed/agent_client_protocol-0.8.0.tar.gz", hash = "sha256:f9eade29167ff72a10fae7a0a0c1f27436909a790e159fb10265c2874e58d922", size = 68577, upload-time = "2026-02-07T17:08:46.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/5c/d60196c536c8f66bb6a238c8a6d0d6fa84a2e3436008c139cb4a79003a25/agent_client_protocol-0.10.0.tar.gz", hash = "sha256:f8a6041e27423131e42e4d0cdd850d5b094b1092f79cc29501ab2bd57b92ee88", size = 81502, upload-time = "2026-05-07T19:11:56.784Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/04/e55a3c549c09c0023cb92c696c7b98d97bb657088f940e34f4bc47d1a49a/agent_client_protocol-0.8.0-py3-none-any.whl", hash = "sha256:2d5712b88b3249dbd6148b24d32c6eb8992e5663f224db6291524ac80cca8037", size = 54362, upload-time = "2026-02-07T17:08:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cc/139895c0c5cd2acefc365085da0735d93df0cf8427ff4ee351961090e1af/agent_client_protocol-0.10.0-py3-none-any.whl", hash = "sha256:97a1a69c5d094e2625b09c4dbc2d5cf19637c4943630ceed52ab0578ecd5e14c", size = 65400, upload-time = "2026-05-07T19:11:55.614Z" }, ] [[package]] @@ -1330,7 +1330,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.8.0" }, + { name = "agent-client-protocol", specifier = "==0.10.0" }, { name = "aiofiles", specifier = ">=24.0,<26.0" }, { name = "aiohttp", specifier = "==3.13.3" }, { name = "batrachian-toad", marker = "python_full_version >= '3.14'", specifier = "==0.5.23" },