Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 14 additions & 35 deletions src/kimi_cli/acp/server.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

import asyncio
import sys
import time
from datetime import datetime
from pathlib import Path
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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"],
Comment thread
huntharo marked this conversation as resolved.
env={},
),
]

Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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
],
}
)

Expand Down
49 changes: 39 additions & 10 deletions src/kimi_cli/acp/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__(
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand All @@ -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",
),
)
Expand All @@ -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",
),
)
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
19 changes: 19 additions & 0 deletions tests/acp/test_cli_auth_command.py
Original file line number Diff line number Diff line change
@@ -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]
35 changes: 6 additions & 29 deletions tests/acp/test_server_initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,47 +2,24 @@

from __future__ import annotations

from unittest.mock import patch

import pytest

from kimi_cli.acp.server import ACPServer

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 == {}
45 changes: 44 additions & 1 deletion tests/acp/test_session_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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()
Expand All @@ -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__()
Expand Down
Loading
Loading