diff --git a/CHANGELOG.md b/CHANGELOG.md index f8cf02b30..73ca9bd17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ Only write entries that are worth mentioning to users. - Web: Show placeholder text in prompt input with hints for slash commands and file mentions - Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits - Web: Improve session stop handling with proper async cleanup and timeout +- ACP: Add protocol version negotiation framework for client-server compatibility +- ACP: Add session resume method to restore session state (experimental) +- ACP: Add session fork method to clone session state (experimental) ## 1.11.0 (2026-02-10) diff --git a/docs/en/reference/kimi-web.md b/docs/en/reference/kimi-web.md index 473e1671c..83edeef9a 100644 --- a/docs/en/reference/kimi-web.md +++ b/docs/en/reference/kimi-web.md @@ -182,12 +182,13 @@ Session search feature added in version 1.5. Directory auto-creation prompt adde Web UI provides a unified prompt toolbar above the input box, displaying various information in collapsible tabs: +- **Context usage**: Shows the current context usage percentage. Hover to view detailed token usage breakdown (including input/output tokens, cache read/write, etc.) - **Activity status**: Shows the current agent state (processing, waiting for approval, etc.) - **Message queue**: Queue follow-up messages while the AI is processing; queued messages are sent automatically when the current response completes - **File changes**: Detects Git repository status, showing the number of new, modified, and deleted files (including untracked files). Click to view a detailed list of changes ::: info Changed -Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Later versions unified it into the prompt toolbar, integrating activity status, message queue, and file changes. +Git diff status bar added in version 1.5. Activity status indicator added in version 1.9. Version 1.10 unified it into the prompt toolbar. Version 1.11 moved the context usage indicator to the prompt toolbar. ::: ### Open-in functionality diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 2efdf0c24..35e9b5ba4 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -7,6 +7,9 @@ This page documents the changes in each Kimi Code CLI release. - Web: Show placeholder text in prompt input with hints for slash commands and file mentions - Web: Fix Ctrl+C not working in uvicorn web server by restoring default SIGINT handler and terminal state after shell mode exits - Web: Improve session stop handling with proper async cleanup and timeout +- ACP: Add protocol version negotiation framework for client-server compatibility +- ACP: Add session resume method to restore session state (experimental) +- ACP: Add session fork method to clone session state (experimental) ## 1.11.0 (2026-02-10) diff --git a/docs/zh/configuration/data-locations.md b/docs/zh/configuration/data-locations.md index 12686b703..fe89bf1a8 100644 --- a/docs/zh/configuration/data-locations.md +++ b/docs/zh/configuration/data-locations.md @@ -112,4 +112,3 @@ Wire 消息记录文件,以 JSONL 格式存储会话中的 Wire 事件。用 | 清理日志 | 删除 `~/.kimi/logs/` 目录 | | 清理 MCP 配置 | 删除 `~/.kimi/mcp.json` 或使用 `kimi mcp remove` | | 清理登录凭据 | 删除 `~/.kimi/credentials/` 目录或使用 `/logout` | - diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 93e56b514..7c2098122 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -140,4 +140,3 @@ export KIMI_CLI_NO_AUTO_UPDATE="1" ::: tip 提示 如果你通过 Nix 或其他包管理器安装 Kimi Code CLI,通常会自动设置此环境变量,因为更新由包管理器处理。 ::: - diff --git a/docs/zh/configuration/overrides.md b/docs/zh/configuration/overrides.md index a4dfdaa78..0ab39b68d 100644 --- a/docs/zh/configuration/overrides.md +++ b/docs/zh/configuration/overrides.md @@ -87,4 +87,3 @@ max_context_size = 262144 | `KIMI_API_KEY=sk-env kimi` | 配置文件 | 环境变量 | 配置文件 | | `kimi --model other` | 配置文件 | 配置文件 | CLI 参数 | | `KIMI_MODEL_NAME=k2 kimi` | 配置文件 | 配置文件 | 环境变量 | - diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 7e5c34655..028d1c80c 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -149,4 +149,3 @@ capabilities = ["thinking", "image_in"] | `moonshot_fetch` | `FetchURL` | 回退到本地抓取 | 使用其他平台时,`FetchURL` 工具仍可使用,但会回退到本地抓取。 - diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 5ac9ea48b..3c90bb028 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -96,4 +96,3 @@ kimi --yolo ::: warning 注意 YOLO 模式会跳过所有确认,请确保你了解可能的风险。建议仅在可控环境中使用。 ::: - diff --git a/docs/zh/guides/use-cases.md b/docs/zh/guides/use-cases.md index 43010276b..790a2e0b7 100644 --- a/docs/zh/guides/use-cases.md +++ b/docs/zh/guides/use-cases.md @@ -110,4 +110,3 @@ Kimi Code CLI 可以执行各种重复性的小任务: ``` 把 images 目录下的所有 PNG 图片转换为 JPEG 格式,保存到 output 目录 ``` - diff --git a/docs/zh/reference/kimi-web.md b/docs/zh/reference/kimi-web.md index 7ab0cbc79..f94beac62 100644 --- a/docs/zh/reference/kimi-web.md +++ b/docs/zh/reference/kimi-web.md @@ -182,12 +182,13 @@ Web UI 提供了便捷的会话管理界面: Web UI 在输入框上方提供统一的提示工具栏,以可折叠标签页的形式展示多种信息: +- **上下文用量**:显示当前上下文的使用百分比,悬停可查看详细的 Token 用量明细(包括输入/输出 Token、缓存读取/写入等) - **活动状态**:显示 Agent 当前状态(处理中、等待审批等) - **消息队列**:在 AI 处理过程中可以排队发送后续消息,待当前回复完成后自动发送 - **文件变更**:检测 Git 仓库状态,显示新增、修改和删除的文件数量(包含未跟踪文件),点击可查看详细的变更列表 ::: info 变更 -Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。后续版本将其统一为提示工具栏,整合活动状态、消息队列和文件变更。 +Git diff 状态栏新增于 1.5 版本。1.9 版本添加了活动状态指示器。1.10 版本将其统一为提示工具栏。1.11 版本将上下文用量指示器移至提示工具栏。 ::: ### Open-in 功能 diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 139b6b71b..ed2d8f2e7 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -7,6 +7,9 @@ - Web:在提示输入框中显示引导占位文本,提示可使用斜杠命令和 @ 引用文件 - Web:修复在 uvicorn Web 服务器中 Ctrl+C 无法使用的问题,在 Shell 模式退出后恢复默认的 SIGINT 信号处理程序和终端状态 - Web:改进会话停止处理,使用正确的异步清理和超时机制 +- ACP:添加协议版本协商框架,用于客户端与服务端之间的兼容性校验 +- ACP:添加会话恢复方法,用于恢复会话状态(实验性) +- ACP:添加会话分支(fork)方法,用于克隆会话状态(实验性) ## 1.11.0 (2026-02-10) diff --git a/pyproject.toml b/pyproject.toml index 1abced6b7..e449e98ab 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.7.0", + "agent-client-protocol==0.8.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 5e77a0eed..c9088dfd1 100644 --- a/src/kimi_cli/acp/server.py +++ b/src/kimi_cli/acp/server.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import shutil import sys from datetime import datetime from pathlib import Path @@ -14,6 +15,7 @@ from kimi_cli.acp.session import ACPSession from kimi_cli.acp.tools import replace_tools from kimi_cli.acp.types import ACPContentBlock, MCPServer +from kimi_cli.acp.version import ACPVersionSpec, negotiate_version from kimi_cli.app import KimiCLI from kimi_cli.config import LLMModel, load_config, save_config from kimi_cli.constant import NAME, VERSION @@ -29,6 +31,7 @@ def __init__(self) -> None: self.client_capabilities: acp.schema.ClientCapabilities | None = None self.conn: acp.Client | None = None self.sessions: dict[str, tuple[ACPSession, _ModelIDConv]] = {} + self.negotiated_version: ACPVersionSpec | None = None def on_connect(self, conn: acp.Client) -> None: logger.info("ACP client connected") @@ -41,10 +44,13 @@ async def initialize( client_info: acp.schema.Implementation | None = None, **kwargs: Any, ) -> acp.InitializeResponse: + self.negotiated_version = negotiate_version(protocol_version) logger.info( - "ACP server initialized with protocol version: {version}, " + "ACP server initialized with client protocol version: {version}, " + "negotiated version: {negotiated}, " "client capabilities: {capabilities}, client info: {info}", version=protocol_version, + negotiated=self.negotiated_version, capabilities=client_capabilities, info=client_info, ) @@ -59,7 +65,7 @@ async def initialize( args = sys.argv[1 : idx + 1] return acp.InitializeResponse( - protocol_version=protocol_version, + protocol_version=self.negotiated_version.protocol_version, agent_capabilities=acp.schema.AgentCapabilities( load_session=True, prompt_capabilities=acp.schema.PromptCapabilities( @@ -67,7 +73,9 @@ async def initialize( ), mcp_capabilities=acp.schema.McpCapabilities(http=True, sse=False), session_capabilities=acp.schema.SessionCapabilities( + fork=acp.schema.SessionForkCapabilities(), list=acp.schema.SessionListCapabilities(), + resume=acp.schema.SessionResumeCapabilities(), ), ), auth_methods=[ @@ -105,16 +113,14 @@ async def initialize( agent_info=acp.schema.Implementation(name=NAME, version=VERSION), ) - async def new_session( - self, cwd: str, mcp_servers: list[MCPServer], **kwargs: Any - ) -> acp.NewSessionResponse: - logger.info("Creating new session for working directory: {cwd}", cwd=cwd) + async def _init_session( + self, session: Session, mcp_servers: list[MCPServer] | None = None + ) -> tuple[ACPSession, _ModelIDConv]: + """Initialize a KimiCLI instance for a session and register it.""" assert self.conn is not None, "ACP client not connected" assert self.client_capabilities is not None, "ACP connection not initialized" - session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd))) - - mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers) + mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers or []) cli_instance = await KimiCLI.create( session, mcp_configs=[mcp_config], @@ -134,6 +140,18 @@ async def new_session( cli_instance.soul.runtime, ) + return acp_session, model_id_conv + + async def new_session( + self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any + ) -> acp.NewSessionResponse: + logger.info("Creating new session for working directory: {cwd}", cwd=cwd) + assert self.conn is not None + + session = await Session.create(KaosPath.unsafe_from_local_path(Path(cwd))) + acp_session, model_id_conv = await self._init_session(session, mcp_servers) + config = acp_session.cli.soul.runtime.config + available_commands = [ acp.schema.AvailableCommand(name=cmd.name, description=cmd.description) for cmd in soul_slash_registry.list_commands() @@ -165,46 +183,106 @@ async def new_session( ), ) + async def _setup_session( + self, + cwd: str, + session_id: str, + mcp_servers: list[MCPServer] | None = None, + ) -> tuple[ACPSession, _ModelIDConv]: + """Load or resume a session. Shared by load_session and resume_session.""" + work_dir = KaosPath.unsafe_from_local_path(Path(cwd)) + session = await Session.find(work_dir, session_id) + if session is None: + logger.error( + "Session not found: {id} for working directory: {cwd}", id=session_id, cwd=cwd + ) + raise acp.RequestError.invalid_params({"session_id": "Session not found"}) + + return await self._init_session(session, mcp_servers) + async def load_session( - self, cwd: str, mcp_servers: list[MCPServer], session_id: str, **kwargs: Any + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> None: logger.info("Loading session: {id} for working directory: {cwd}", id=session_id, cwd=cwd) - assert self.conn is not None, "ACP client not connected" - assert self.client_capabilities is not None, "ACP connection not initialized" if session_id in self.sessions: logger.warning("Session already loaded: {id}", id=session_id) return + await self._setup_session(cwd, session_id, mcp_servers) + # TODO: replay session history? + + async def resume_session( + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any + ) -> acp.schema.ResumeSessionResponse: + logger.info("Resuming session: {id} for working directory: {cwd}", id=session_id, cwd=cwd) + + if session_id not in self.sessions: + await self._setup_session(cwd, session_id, mcp_servers) + + acp_session, model_id_conv = self.sessions[session_id] + config = acp_session.cli.soul.runtime.config + return acp.schema.ResumeSessionResponse( + modes=acp.schema.SessionModeState( + available_modes=[ + acp.schema.SessionMode( + id="default", + name="Default", + description="The default mode.", + ), + ], + current_mode_id="default", + ), + models=acp.schema.SessionModelState( + available_models=_expand_llm_models(config.models), + current_model_id=model_id_conv.to_acp_model_id(), + ), + ) + + async def fork_session( + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any + ) -> acp.schema.ForkSessionResponse: + logger.info("Forking session: {id} for working directory: {cwd}", id=session_id, cwd=cwd) + work_dir = KaosPath.unsafe_from_local_path(Path(cwd)) - session = await Session.find(work_dir, session_id) - if session is None: + source_session = await Session.find(work_dir, session_id) + if source_session is None: logger.error( - "Session not found: {id} for working directory: {cwd}", id=session_id, cwd=cwd + "Source session not found: {id} for working directory: {cwd}", + id=session_id, + cwd=cwd, ) raise acp.RequestError.invalid_params({"session_id": "Session not found"}) - mcp_config = acp_mcp_servers_to_mcp_config(mcp_servers) - cli_instance = await KimiCLI.create( - session, - mcp_configs=[mcp_config], - ) - config = cli_instance.soul.runtime.config - acp_kaos = ACPKaos(self.conn, session.id, self.client_capabilities) - acp_session = ACPSession(session.id, cli_instance, self.conn, kaos=acp_kaos) - model_id_conv = _ModelIDConv(config.default_model, config.default_thinking) - self.sessions[session.id] = (acp_session, model_id_conv) + new_session = await Session.create(work_dir) - if isinstance(cli_instance.soul.agent.toolset, KimiToolset): - replace_tools( - self.client_capabilities, - self.conn, - session.id, - cli_instance.soul.agent.toolset, - cli_instance.soul.runtime, - ) + # Copy context and wire files from source to new session + for filename in ("context.jsonl", "wire.jsonl"): + src_file = source_session.dir / filename + dst_file = new_session.dir / filename + if src_file.exists(): + shutil.copy2(src_file, dst_file) - # TODO: replay session history? + acp_session, model_id_conv = await self._init_session(new_session, mcp_servers) + config = acp_session.cli.soul.runtime.config + + return acp.schema.ForkSessionResponse( + session_id=new_session.id, + modes=acp.schema.SessionModeState( + available_modes=[ + acp.schema.SessionMode( + id="default", + name="Default", + description="The default mode.", + ), + ], + current_mode_id="default", + ), + models=acp.schema.SessionModelState( + available_models=_expand_llm_models(config.models), + current_model_id=model_id_conv.to_acp_model_id(), + ), + ) async def list_sessions( self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any diff --git a/src/kimi_cli/acp/version.py b/src/kimi_cli/acp/version.py new file mode 100644 index 000000000..6c51cd0d3 --- /dev/null +++ b/src/kimi_cli/acp/version.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ACPVersionSpec: + """Describes one supported ACP protocol version.""" + + protocol_version: int # negotiation integer (currently 1) + spec_tag: str # ACP spec tag (e.g. "v0.10.8") + sdk_version: str # corresponding SDK version (e.g. "0.8.0") + + +CURRENT_VERSION = ACPVersionSpec( + protocol_version=1, + spec_tag="v0.10.8", + sdk_version="0.8.0", +) + +SUPPORTED_VERSIONS: dict[int, ACPVersionSpec] = { + 1: CURRENT_VERSION, +} + +MIN_PROTOCOL_VERSION = 1 + + +def negotiate_version(client_protocol_version: int) -> ACPVersionSpec: + """Negotiate the protocol version with the client. + + Returns the highest server-supported version that does not exceed the + client's requested version. If the client version is lower than + ``MIN_PROTOCOL_VERSION`` the server still returns its own current + version so the client can decide whether to disconnect. + """ + if client_protocol_version < MIN_PROTOCOL_VERSION: + return CURRENT_VERSION + + # Find the highest supported version <= client version + best: ACPVersionSpec | None = None + for ver, spec in SUPPORTED_VERSIONS.items(): + if ver <= client_protocol_version and (best is None or ver > best.protocol_version): + best = spec + + return best if best is not None else CURRENT_VERSION diff --git a/src/kimi_cli/ui/acp/__init__.py b/src/kimi_cli/ui/acp/__init__.py index 6a6c58249..b71668bdc 100644 --- a/src/kimi_cli/ui/acp/__init__.py +++ b/src/kimi_cli/ui/acp/__init__.py @@ -35,15 +35,25 @@ async def initialize( self._raise() async def new_session( - self, cwd: str, mcp_servers: list[MCPServer], **kwargs: Any + self, cwd: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> acp.NewSessionResponse: self._raise() async def load_session( - self, cwd: str, mcp_servers: list[MCPServer], session_id: str, **kwargs: Any + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any ) -> None: self._raise() + async def resume_session( + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any + ) -> acp.schema.ResumeSessionResponse: + self._raise() + + async def fork_session( + self, cwd: str, session_id: str, mcp_servers: list[MCPServer] | None = None, **kwargs: Any + ) -> acp.schema.ForkSessionResponse: + self._raise() + async def list_sessions( self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any ) -> acp.schema.ListSessionsResponse: diff --git a/tests/acp/__init__.py b/tests/acp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/acp/conftest.py b/tests/acp/conftest.py new file mode 100644 index 000000000..b12e4ea65 --- /dev/null +++ b/tests/acp/conftest.py @@ -0,0 +1,158 @@ +"""ACP test configuration and fixtures.""" + +from __future__ import annotations + +import json +import os +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +import acp +import pytest +import pytest_asyncio + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _kimi_bin() -> str: + """Return the path to the kimi entry-point script inside the venv.""" + return str(_repo_root() / ".venv" / "bin" / "kimi") + + +class ACPTestClient: + """Minimal ACP client for tests — collects session_update callbacks.""" + + def __init__(self) -> None: + self.updates: list[Any] = [] + self.conn: acp.Agent | None = None + + def on_connect(self, conn: acp.Agent) -> None: + self.conn = conn + + async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None: + self.updates.append(update) + + async def request_permission( + self, + options: list[acp.schema.PermissionOption], + session_id: str, + tool_call: acp.schema.ToolCallUpdate, + **kwargs: Any, + ) -> acp.schema.RequestPermissionResponse: + return acp.schema.RequestPermissionResponse( + outcome=acp.schema.AllowedOutcome( + outcome="selected", + option_id="allow", + ) + ) + + async def read_text_file( + self, + path: str, + session_id: str, + limit: int | None = None, + line: int | None = None, + **kwargs: Any, + ) -> Any: + raise NotImplementedError + + async def write_text_file(self, content: str, path: str, session_id: str, **kwargs: Any) -> Any: + raise NotImplementedError + + async def create_terminal( + self, + command: str, + session_id: str, + args: list[str] | None = None, + cwd: str | None = None, + env: list[acp.schema.EnvVariable] | None = None, + output_byte_limit: int | None = None, + **kwargs: Any, + ) -> Any: + raise NotImplementedError + + async def terminal_output(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any: + raise NotImplementedError + + async def wait_for_terminal_exit(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any: + raise NotImplementedError + + async def kill_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any: + raise NotImplementedError + + async def release_terminal(self, session_id: str, terminal_id: str, **kwargs: Any) -> Any: + raise NotImplementedError + + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: + pass + + +@pytest.fixture +def acp_share_dir(tmp_path: Path) -> Path: + """Create a share dir with _scripted_echo config at config.toml.""" + share_dir = tmp_path / "share" + share_dir.mkdir() + + scripts = [ + "text: Hello from scripted echo!", + "text: Second response from scripted echo.", + ] + scripts_path = tmp_path / "scripts.json" + scripts_path.write_text(json.dumps(scripts), encoding="utf-8") + + trace_env = os.getenv("KIMI_SCRIPTED_ECHO_TRACE", "0") + config_data = { + "default_model": "scripted", + "models": { + "scripted": { + "provider": "scripted_provider", + "model": "scripted_echo", + "max_context_size": 100000, + } + }, + "providers": { + "scripted_provider": { + "type": "_scripted_echo", + "base_url": "", + "api_key": "", + "env": { + "KIMI_SCRIPTED_ECHO_SCRIPTS": str(scripts_path), + "KIMI_SCRIPTED_ECHO_TRACE": trace_env, + }, + } + }, + } + + import tomlkit + + config_path = share_dir / "config.toml" + config_path.write_text(tomlkit.dumps(config_data), encoding="utf-8") + return share_dir + + +@pytest_asyncio.fixture +async def acp_client( + acp_share_dir: Path, tmp_path: Path +) -> AsyncIterator[tuple[acp.ClientSideConnection, ACPTestClient]]: + """Spawn a kimi ACP subprocess and return the SDK connection + test client.""" + test_client = ACPTestClient() + env = { + **os.environ, + "KIMI_SHARE_DIR": str(acp_share_dir), + } + + async with acp.spawn_agent_process( + test_client, + _kimi_bin(), + "acp", + env=env, + cwd=str(_repo_root()), + use_unstable_protocol=True, + ) as (conn, process): + yield conn, test_client diff --git a/tests/acp/test_protocol_v1.py b/tests/acp/test_protocol_v1.py new file mode 100644 index 000000000..225857b6c --- /dev/null +++ b/tests/acp/test_protocol_v1.py @@ -0,0 +1,243 @@ +"""Protocol V1 consistency tests using the real ACP SDK client.""" + +from __future__ import annotations + +import acp +import pytest + +from kimi_cli.acp.version import CURRENT_VERSION + +from .conftest import ACPTestClient + +pytestmark = pytest.mark.asyncio + + +async def test_initialize_returns_negotiated_version( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], +): + """initialize(protocol_version=1) returns version 1 with expected fields.""" + conn, _ = acp_client + resp = await conn.initialize(protocol_version=1) + + assert resp.protocol_version == 1 + assert resp.agent_capabilities is not None + assert resp.agent_capabilities.prompt_capabilities is not None + assert resp.agent_info is not None + assert resp.agent_info.name == "Kimi Code CLI" + + +async def test_initialize_with_higher_version( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], +): + """initialize(protocol_version=99) returns the server's current max version.""" + conn, _ = acp_client + resp = await conn.initialize(protocol_version=99) + + assert resp.protocol_version == CURRENT_VERSION.protocol_version + + +async def test_new_session_response_shape( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """new_session returns session_id, modes, and models.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + resp = await conn.new_session(cwd=str(work_dir)) + + assert isinstance(resp.session_id, str) + assert len(resp.session_id) > 0 + assert resp.modes is not None + assert resp.models is not None + + +async def test_prompt_with_scripted_echo( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """Full flow: initialize → new_session → prompt returns a valid response.""" + conn, test_client = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + resp = await conn.prompt( + prompt=[acp.text_block("Say hello")], + session_id=session_resp.session_id, + ) + + assert resp.stop_reason in ("end_turn", "max_tokens", "max_turn_requests") + # The scripted echo provider should have sent session updates + assert len(test_client.updates) > 0 + + +async def test_list_sessions( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """After creating a session and prompting, list_sessions returns it.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + # Must prompt first; Session.list() skips empty sessions + await conn.prompt( + prompt=[acp.text_block("Hello")], + session_id=session_resp.session_id, + ) + + list_resp = await conn.list_sessions(cwd=str(work_dir)) + session_ids = [s.session_id for s in list_resp.sessions] + assert session_resp.session_id in session_ids + + +async def test_resume_session( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """initialize → new_session → prompt → resume_session returns modes and models.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + # Must prompt first so the session is persisted + await conn.prompt( + prompt=[acp.text_block("Hello")], + session_id=session_resp.session_id, + ) + + resume_resp = await conn.resume_session( + cwd=str(work_dir), + session_id=session_resp.session_id, + ) + + assert resume_resp.modes is not None + assert resume_resp.modes.current_mode_id == "default" + assert len(resume_resp.modes.available_modes) > 0 + assert resume_resp.models is not None + assert isinstance(resume_resp.models.current_model_id, str) + assert len(resume_resp.models.available_models) > 0 + + +async def test_resume_session_not_found( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """resume_session with a non-existent session_id raises an error.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + + with pytest.raises(acp.RequestError): + await conn.resume_session( + cwd=str(work_dir), + session_id="non-existent-session-id", + ) + + +async def test_fork_session( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """fork_session returns a new session_id with modes and models.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + # Prompt so the session has history to fork + await conn.prompt( + prompt=[acp.text_block("Hello")], + session_id=session_resp.session_id, + ) + + fork_resp = await conn.fork_session( + cwd=str(work_dir), + session_id=session_resp.session_id, + ) + + assert isinstance(fork_resp.session_id, str) + assert fork_resp.session_id != session_resp.session_id + assert fork_resp.modes is not None + assert fork_resp.models is not None + + +async def test_fork_session_not_found( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """fork_session with a non-existent session_id raises an error.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + + with pytest.raises(acp.RequestError): + await conn.fork_session( + cwd=str(work_dir), + session_id="non-existent-session-id", + ) + + +async def test_fork_session_prompt( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """After forking, the new session can handle prompts normally.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + # Prompt the original session + await conn.prompt( + prompt=[acp.text_block("Hello")], + session_id=session_resp.session_id, + ) + + fork_resp = await conn.fork_session( + cwd=str(work_dir), + session_id=session_resp.session_id, + ) + + # Prompt on the forked session should work + prompt_resp = await conn.prompt( + prompt=[acp.text_block("Hi from forked session")], + session_id=fork_resp.session_id, + ) + + assert prompt_resp.stop_reason in ("end_turn", "max_tokens", "max_turn_requests") + + +async def test_cancel_session( + acp_client: tuple[acp.ClientSideConnection, ACPTestClient], + tmp_path, +): + """cancel on an idle session completes without error.""" + conn, _ = acp_client + await conn.initialize(protocol_version=1) + + work_dir = tmp_path / "workdir" + work_dir.mkdir(exist_ok=True) + session_resp = await conn.new_session(cwd=str(work_dir)) + + # cancel should not raise + await conn.cancel(session_id=session_resp.session_id) diff --git a/tests/acp/test_version.py b/tests/acp/test_version.py new file mode 100644 index 000000000..2b3861870 --- /dev/null +++ b/tests/acp/test_version.py @@ -0,0 +1,56 @@ +"""Unit tests for ACP version negotiation.""" + +from __future__ import annotations + +import dataclasses + +from kimi_cli.acp.version import ( + CURRENT_VERSION, + MIN_PROTOCOL_VERSION, + SUPPORTED_VERSIONS, + negotiate_version, +) + + +def test_negotiate_current_version(): + """Client sends protocol_version=1 → server returns v1.""" + result = negotiate_version(1) + assert result.protocol_version == 1 + assert result is CURRENT_VERSION + + +def test_negotiate_future_version(): + """Client sends protocol_version=99 → server returns the highest supported version.""" + result = negotiate_version(99) + max_supported = max(SUPPORTED_VERSIONS.keys()) + assert result.protocol_version == max_supported + assert result is SUPPORTED_VERSIONS[max_supported] + + +def test_negotiate_zero_version(): + """Client sends protocol_version=0 (below minimum) → server returns CURRENT_VERSION.""" + result = negotiate_version(0) + assert result is CURRENT_VERSION + + +def test_negotiate_negative_version(): + """Client sends negative protocol_version → server returns CURRENT_VERSION.""" + result = negotiate_version(-1) + assert result is CURRENT_VERSION + + +def test_version_spec_immutable(): + """CURRENT_VERSION is a frozen dataclass and cannot be mutated.""" + assert dataclasses.is_dataclass(CURRENT_VERSION) + assert CURRENT_VERSION.__dataclass_params__.frozen # type: ignore[attr-defined] + + +def test_supported_versions_contains_current(): + """SUPPORTED_VERSIONS includes at least the CURRENT_VERSION.""" + assert CURRENT_VERSION.protocol_version in SUPPORTED_VERSIONS + assert SUPPORTED_VERSIONS[CURRENT_VERSION.protocol_version] is CURRENT_VERSION + + +def test_min_protocol_version_consistency(): + """MIN_PROTOCOL_VERSION is the smallest key in SUPPORTED_VERSIONS.""" + assert min(SUPPORTED_VERSIONS.keys()) == MIN_PROTOCOL_VERSION diff --git a/uv.lock b/uv.lock index 036a1a6a5..13cddd88a 100644 --- a/uv.lock +++ b/uv.lock @@ -17,14 +17,14 @@ members = [ [[package]] name = "agent-client-protocol" -version = "0.7.0" +version = "0.8.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/4d/e33e4e997de8fdc6c7154e59490a20c455cd46543b62dab768ae99317046/agent_client_protocol-0.7.0.tar.gz", hash = "sha256:c66811bb804868c4e7728b18b67379bcb0335afba3b1c2ff0fcdfd0c48d93029", size = 64809, upload-time = "2025-12-04T16:17:34.568Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/02/257ea400cfee72a48dabe04ef0a984c496c9687830cf7977b327979e8cd7/agent_client_protocol-0.7.0-py3-none-any.whl", hash = "sha256:71fce4088fe7faa85b30278aecd1d8d6012f03505ae2ee6e312f9e2ba4ea1f4e", size = 52922, upload-time = "2025-12-04T16:17:33.562Z" }, + { 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" }, ] [[package]] @@ -1255,7 +1255,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "agent-client-protocol", specifier = "==0.7.0" }, + { name = "agent-client-protocol", specifier = "==0.8.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" },