diff --git a/e2e/bub/README.md b/e2e/bub/README.md index f8010dda19..b23e5a1e26 100644 --- a/e2e/bub/README.md +++ b/e2e/bub/README.md @@ -6,8 +6,8 @@ on Memory collection, grounding, and recall rather than the native task reward. The common architecture separates workload selection, execution, evidence, Memory evaluation, and reporting. Bub is the execution adapter for acceptance workloads because its model, tools, context injection, capture, and checkpoints -are observable. The OFF/ON comparison can also run on Codex, Claude Code, OpenCode, and Pi, without changing the -workload or evaluation contracts. +are observable. The OFF/ON comparison can also run on Codex, Claude Code, OpenCode, Pi, and Kimi Code, without +changing the workload or evaluation contracts. Every workload follows one execution path: @@ -168,8 +168,8 @@ covers the workspace only; a file the agent writes elsewhere in the container is The `paired` command runs each selected workload with PowerContext off and on, in separate containers, and repeats this for `--trials` trials. The arm that runs first alternates between trials. `--host` selects the agent host for -both arms: `bub` by default, `codex`, `claude-code`, `opencode`, or `pi`. Each host uses its own PowerContext -integration, so ON means what that integration does for its users. +both arms: `bub` by default, `codex`, `claude-code`, `opencode`, `pi`, or `kimi-code`. Each host uses its own +PowerContext integration, so ON means what that integration does for its users. - OFF is the host as a user without PowerContext has it. The agent is not told that PowerContext is off, and it cannot find PowerContext: no integration is installed, no PowerContext sources are mounted in its container, and it @@ -179,20 +179,20 @@ integration, so ON means what that integration does for its users. - ON installs the integration, binds it to a new Scope, and gives it the harness Client's Server token. For Bub this means the plugin with `capture_events` enabled, so that, like the other host integrations, it captures what the user says without relying on the model to call a memory tool. This is not the plugin's default setting. Codex runs - with `--enable plugins`, Claude Code has the plugin enabled, OpenCode loads it from its plugins directory, and Pi - loads the installed package. In all four, the plugin captures each user prompt and asks for context before each - turn, and the plugin's tools (MCP for Codex and Claude Code, native tools for OpenCode and Pi) and Skill are - available to the model. + with `--enable plugins`, Claude Code has the plugin enabled, OpenCode loads it from its plugins directory, Pi loads + the installed package, and Kimi Code loads the plugin registered in its home. In all five, the plugin captures each + user prompt and asks for context before each turn, and the plugin's tools (MCP for Codex, Claude Code, and Kimi + Code, native tools for OpenCode and Pi) and Skill are available to the model. - Everything else is the same in both arms: image, host version, model, reasoning settings, and budget. Both arms' containers can reach the Server, and the Server keeps the ON arms' Memory across trials. The command therefore runs only against a Server that requires authentication: it stops before the first run if the Server lists its Scopes to a client without a token. Start the Server with `POWERCONTEXT_SERVER_ACCESS_MODE=enforced` and a `POWERCONTEXT_SERVER_AUTH_TOKEN`, and give the harness the same value as `POWERCONTEXT_CLIENT_API_TOKEN`; the harness -passes it to the ON arm's integration as `POWERCONTEXT_BUB_API_TOKEN` or `POWERCONTEXT__AUTHORIZATION`. The -harness holds that value in its own environment and gives Harbor a reference to it, so Harbor's job files record the -reference and no part of the token. The token still lets an ON agent read other Scopes on the same Server, including -earlier trials'. +passes it to the ON arm's integration as `POWERCONTEXT_BUB_API_TOKEN`, `POWERCONTEXT_KIMI_TOKEN`, or +`POWERCONTEXT__AUTHORIZATION`. The harness holds that value in its own environment and gives Harbor a +reference to it, so Harbor's job files record the reference and no part of the token. The token still lets an ON +agent read other Scopes on the same Server, including earlier trials'. After each ON session the harness records the Scope's Server statistics. When another session follows, it first flushes the Scope, standing in for the time that passes between real sessions, and repeats the flush until the Scope has @@ -211,10 +211,10 @@ An ON run counts only when Server statistics for its Scope show that Sources wer Otherwise it is an integration failure. Whether a flush creates Memory and whether recall returns content are PowerContext's own behavior, so the snapshots record them but a run that gets nothing useful still counts as an ON attempt. Integration failures and harness or infrastructure errors are reported but left out of success rates and -paired differences. A session whose model request failed is such an error on every host: Codex and Claude Code exit -non-zero, Harbor reads OpenCode's error events, and the harness reads Pi's last message, because Pi exits 0 in the -JSON mode Harbor uses. The harness reads Pi's output through the logs that Harbor's Docker environment mounts and -stops with an error when the file is not there. An agent timeout counts as a failed attempt in either arm. +paired differences. A session whose model request failed is such an error on every host: Codex, Claude Code, and Kimi +Code exit non-zero, Harbor reads OpenCode's error events, and the harness reads Pi's last message, because Pi exits 0 +in the JSON mode Harbor uses. The harness reads Pi's output through the logs that Harbor's Docker environment mounts +and stops with an error when the file is not there. An agent timeout counts as a failed attempt in either arm. The harness Client waits for each flush, which runs the Server's generation model, so raise its 10-second default timeout; the Bub plugin also flushes during a session. @@ -328,6 +328,38 @@ export OPENROUTER_API_KEY=replace-me make harness-paired ARGS='--host pi --trials 2' ``` +Kimi Code 2.1.1, the version PowerContext's Kimi Code acceptance workflow tests, runs through the harness's own +agent, because Harbor 0.16's Kimi agent installs the earlier Python `kimi-cli`. The agent installs Node 24.21.0 and +`@moonshot-ai/kimi-code`, and runs each session with `kimi -p`, which runs every tool without asking. Kimi Code +installs plugins only from its interactive interface, so the ON arm writes the plugin's copy and registry entry where +`powercontext setup kimi-code` puts them, configured with the arm's Scope; the agent container sees only the plugin's +`kimi.plugin.json`, `hooks`, `scripts`, `shared`, and `skills`. The plugin takes the Server token from +`POWERCONTEXT_KIMI_TOKEN` without the Bearer scheme, reads its Server URL only from that configuration, takes it only +as an origin, and accepts plain HTTP only on loopback. When `POWERCONTEXT_KIMI_SERVER_URL` is not an HTTP or HTTPS +origin, every arm therefore ends as an error before its container starts. For a plain-HTTP origin such as +`host-gateway`, the ON arm runs a relay on `127.0.0.1:47731` in its container and configures the plugin with that +address. Kimi Code keeps its sessions, the files they edited, a search cache, and logs in its home, so before each +session in both arms the harness empties the home except for the configuration and the installed plugin, and stops +the arm if Kimi Code still lists a session afterwards. + +The harness selects the model with `POWERCONTEXT_E2E_KIMI_CODE_MODEL` as `openrouter/`, the one provider it +configures, and passes `POWERCONTEXT_E2E_KIMI_CODE_REASONING_EFFORT`, default `medium`, as Kimi Code's thinking +effort, which Kimi Code sends as `reasoning_effort`. Kimi Code asks an OpenAI-compatible provider for up to 131,072 +output tokens per request, so the model's context window must hold that besides the prompt; the harness declares a +262,144-token window. The configuration turns off Kimi Code's telemetry and the extra model request that titles each +session. The agent passes `OPENROUTER_API_KEY`, and reads each step's tokens from the usage Kimi Code records in the +session for every model request, the main agent's and any subagent's. + +```bash +export POWERCONTEXT_CLIENT_SERVER_URL=http://127.0.0.1:8000 +export POWERCONTEXT_CLIENT_API_TOKEN=replace-me +export POWERCONTEXT_CLIENT_TIMEOUT=150 +export POWERCONTEXT_KIMI_SERVER_URL=http://host-gateway:8000 +export POWERCONTEXT_E2E_KIMI_CODE_MODEL=openrouter/qwen/qwen3.7-flash +export OPENROUTER_API_KEY=replace-me +make harness-paired ARGS='--host kimi-code --trials 2' +``` + Each arm writes `observation.json`, which includes the per-session Server snapshots for ON, and its Harbor jobs: ```text @@ -350,12 +382,14 @@ For each workload and over all of them, the report gives: interval over those pairs (10,000 resamples from a fixed seed, so the same evidence gives the same interval), and how many pairs only ON passed, only OFF passed, or tied. - Per step and arm, over the same scored runs: the agent's execution time as Harbor measured it, and the input - tokens, the cached input tokens among them, output tokens, and cost that Harbor's agent for that host records. + tokens, the cached input tokens among them, output tokens, and cost that the host's agent records: Harbor's, or + for Kimi Code the harness's own. Claude Code, OpenCode, and Pi report their own cost; for Codex, Harbor estimates it from its price table and leaves it out for a model the table lacks. A timed-out run is scored, so its time counts. For Bub, Harbor reads input and output tokens from the usage `bub-acp-server` returns with each ACP prompt response, summed over the turn's model calls as the model provider reported them. Harbor reads no cached input tokens from an ACP agent, and Bub reports no cost, so those Bub cells show `n/a`, as do the tokens of a Bub step that timed out before its prompt response. + Kimi Code reports no cost either, so its cost cells show `n/a`. - The Server's usage for the ON arm, as a mean over each scored run's final Scope snapshot: generation and embedding requests and tokens, and the Server's own estimate of the tokens of context it returned. The Server has no price list, so there is no Server cost. OFF runs have no Scope. A timed-out ON run keeps the snapshots it reached, so @@ -365,9 +399,10 @@ Each step figure and Server token count is a mean over the runs that reported it missing figure as zero. Runs can differ: Harbor reads a step's usage from the host's own output, so a step whose host recorded none has no figures, and the Server leaves a Scope's tokens unknown when a model provider did not report them. A timed-out step keeps the usage its host recorded before it was stopped, as it keeps its time. Harbor's agents -for Pi, Claude Code, Codex, and OpenCode do record 0 for a token count missing from a log the host wrote, and the -report cannot tell that from a real 0. The report shows `n/a` when no run reported a figure and adds its count, as in -`1,000 (1 of 2 runs)`, when only some did; `paired-report.json` keeps the count of every figure. +for Pi, Claude Code, Codex, and OpenCode, and the harness's agent for Kimi Code, do record 0 for a token count missing +from a log the host wrote, and the report cannot tell that from a real 0. The report shows `n/a` when no run +reported a figure and adds its count, as in `1,000 (1 of 2 runs)`, when only some did; `paired-report.json` keeps +the count of every figure. The report is marked preliminary. With two trials the intervals are wide, which is the point: they show how little such a pilot can say. The command does not yet check the Default Scope for leaks or run in the fixed Compose harness. diff --git a/e2e/bub/src/powercontext_e2e/__main__.py b/e2e/bub/src/powercontext_e2e/__main__.py index 96a69a3d19..755f181e9a 100644 --- a/e2e/bub/src/powercontext_e2e/__main__.py +++ b/e2e/bub/src/powercontext_e2e/__main__.py @@ -102,7 +102,7 @@ def main() -> None: paired_parser.add_argument("--trials", type=int, default=2, help="Trials per arm; the arm order alternates.") paired_parser.add_argument( "--host", - choices=("bub", "codex", "claude-code", "opencode", "pi"), + choices=("bub", "codex", "claude-code", "opencode", "pi", "kimi-code"), default="bub", help="Agent host that runs both arms with its own PowerContext integration.", ) diff --git a/e2e/bub/src/powercontext_e2e/harbor_kimi_code.py b/e2e/bub/src/powercontext_e2e/harbor_kimi_code.py new file mode 100644 index 0000000000..ec44453b3a --- /dev/null +++ b/e2e/bub/src/powercontext_e2e/harbor_kimi_code.py @@ -0,0 +1,381 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A Harbor agent for Kimi Code, with the local PowerContext Kimi Code plugin installed in the ON arm.""" + +from __future__ import annotations + +import contextlib +import ipaddress +import json +import os +import shlex +from pathlib import Path +from typing import Any, override +from urllib.parse import urlsplit + +from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template +from harbor.environments.base import BaseEnvironment +from harbor.models.agent.context import AgentContext +from harbor.models.trial.paths import EnvironmentPaths + +from .harbor_agent import REMOTE_SOURCE, clear_step_tests + +# The Kimi Code and Node versions that PowerContext's Kimi Code acceptance workflow tests. Harbor 0.16's Kimi agent +# installs the earlier Python kimi-cli, which has another home, configuration, and plugin format. +KIMI_CODE_VERSION = "2.1.1" +KIMI_CODE_PACKAGE = "@moonshot-ai/kimi-code" +NODE_VERSION = "24.21.0" +REMOTE_PLUGIN = f"{REMOTE_SOURCE}/integrations/kimi-code/plugins/powercontext" +# The plugin's hook and Kimi Code's MCP connection read the Server token from here, without the Bearer scheme. +KIMI_TOKEN = "POWERCONTEXT_KIMI_TOKEN" # noqa: S105 - a variable name +# The plugin reads its Scope only from its installed configuration, which the ON arm writes from this setting. +KIMI_SCOPE = "POWERCONTEXT_KIMI_SCOPE_ID" +KIMI_HOME = '"${KIMI_CODE_HOME:-$HOME/.kimi-code}"' +# Kimi Code keeps sessions, the files they edited, a search cache, and logs in its home, and nothing else clears them +# between the steps of a trial. A new session needs only the configuration and the installed plugin. +KIMI_HOME_KEPT = ("config.toml", "plugins") +# What `powercontext setup kimi-code` writes into the copy it installs; a test compares the two installations. +MEMORY_TOOLS = ("search_memory", "remember_memory", "get_artifact_revision") +OWNER_MARKER = ".powercontext-owned" +# The plugin accepts plain HTTP only on loopback, so the ON arm reaches a plain-HTTP Server through a relay that listens +# here. The port is one a workload is unlikely to use, as the relay holds it in the ON arm only. +RELAY_PORT = 47731 +# Where Harbor keeps the agent's logs for each step in the container, and what the agent writes there. +AGENT_LOGS = EnvironmentPaths.agent_dir.as_posix() +OUTPUT_FILENAME = "kimi-code.txt" +SESSION_LOGS = "kimi-code-sessions" +# Kimi Code sizes compaction from this window and asks an OpenAI-compatible provider for up to 131,072 output tokens +# per request, which a model's window must also hold. +MAX_CONTEXT_SIZE = 262144 +# The OpenAI-compatible providers the harness can configure, by the provider part of a `provider/model` name. +PROVIDERS = {"openrouter": ("https://openrouter.ai/api/v1", "OPENROUTER_API_KEY")} +# Keep the pinned Kimi Code version for the whole run. +KIMI_ENV = {"KIMI_CODE_NO_AUTO_UPDATE": "1"} +# Run with Node as `node -e CONFIGURE_PLUGIN ` in the copied +# plugin, as `powercontext setup kimi-code` configures the copy it installs. +CONFIGURE_PLUGIN = f""" +const fs = require('fs') +const path = require('path') +const [home, plugin, source, serverUrl, scopeId, useToken] = process.argv.slice(1) +const write = (file, value) => fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\\n') +const useTokenValue = useToken === 'true' +write(path.join(plugin, 'powercontext.json'), {{ + server_url: serverUrl, scope_id: scopeId, capture_prompts: true, use_token: useTokenValue, +}}) +const manifestPath = path.join(plugin, 'kimi.plugin.json') +const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) +manifest.mcpServers = {{ powercontext: {{ + url: serverUrl + '/mcp', + ...(useTokenValue ? {{ bearerTokenEnvVar: '{KIMI_TOKEN}' }} : {{}}), + enabledTools: {json.dumps(list(MEMORY_TOOLS))}, + startupTimeoutMs: 10000, + toolTimeoutMs: 10000, +}} }} +write(manifestPath, manifest) +const skill = path.join(plugin, 'skills/powercontext-project-context/SKILL.md') +const guidance = fs.readFileSync(skill, 'utf8') +fs.writeFileSync(skill, guidance.replaceAll('__POWERCONTEXT_SCOPE_JSON__', () => JSON.stringify(scopeId))) +const now = new Date().toISOString() +write(path.join(home, 'plugins/installed.json'), {{ version: 1, plugins: [{{ + id: 'powercontext', root: plugin, source: 'local-path', enabled: true, + installedAt: now, updatedAt: now, originalSource: source, +}}] }}) +""" +# Run with Node as `node -e RELAY `: relay each connection to the Server unchanged. +RELAY = ( + "const net = require('net'); const [listen, host, port] = process.argv.slice(1); " + "net.createServer(client => { const server = net.connect(Number(port), host); " + "const close = () => { client.destroy(); server.destroy() }; " + "for (const socket of [client, server]) socket.on('error', close).on('close', close); " + "client.pipe(server).pipe(client) }).listen(Number(listen), '127.0.0.1')" +) + + +class PowerContextKimiCodeAgent(BaseInstalledAgent): + """Run Kimi Code in prompt mode, with the PowerContext Kimi Code plugin installed only in the ON arm. + + As on the other hosts, the OFF arm runs without the plugin. The ON arm configures the plugin as ``powercontext + setup kimi-code`` does, with the arm's Scope and the Server URL as the agent container reaches it; the plugin reads + the Server token from its environment, which only the ON arm receives. Each session starts without Kimi Code's + earlier sessions, as sessions do on the other hosts, so neither arm can read an earlier session from Kimi Code's + own files. + """ + + def __init__( + self, + *, + server_url: str, + powercontext: bool = True, + reasoning_effort: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(version=KIMI_CODE_VERSION, **kwargs) + if self.mcp_servers or self.skills_dir: + raise ValueError("The Kimi Code agent does not register a task's MCP servers or Skills") # noqa: TRY003 + self._config = kimi_config(self.model_name, reasoning_effort) + self._provider_key = PROVIDERS[_provider(self.model_name)][1] + self._powercontext = powercontext + self._plugin_url, self._relay = plugin_endpoint(server_url) + + @staticmethod + @override + def name() -> str: + return "kimi-code" + + @override + def get_version_command(self) -> str | None: + return ". ~/.nvm/nvm.sh; kimi --version" + + @override + async def install(self, environment: BaseEnvironment) -> None: + # The steps of Harbor's Node-based agents, with the Node version Kimi Code is tested on. + await self.exec_as_root( + environment, + command="apt-get update && apt-get install -y curl", + env={"DEBIAN_FRONTEND": "noninteractive"}, + ) + await self.exec_as_agent( + environment, + command=( + "set -euo pipefail; " + "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && " + 'export NVM_DIR="$HOME/.nvm" && ' + '\\. "$NVM_DIR/nvm.sh" || true && ' + "command -v nvm &>/dev/null || { echo 'Error: NVM failed to load' >&2; exit 1; } && " + f"nvm install {NODE_VERSION} && " + # The package's install script only renames a kimi-cli launcher that would shadow it. + f"npm install -g --ignore-scripts {KIMI_CODE_PACKAGE}@{KIMI_CODE_VERSION} && " + "kimi --version" + ), + env=KIMI_ENV, + ) + await self.exec_as_agent(environment, command=write_config_command(self._config)) + if self._powercontext: + await self.exec_as_agent(environment, command=install_plugin_command(self._plugin_url)) + + @with_prompt_template + @override + async def run(self, instruction: str, environment: BaseEnvironment, context: AgentContext) -> None: + if not (key := os.environ.get(self._provider_key)): + raise RuntimeError(f"Kimi Code's model needs {self._provider_key}") # noqa: TRY003 + await self.exec_as_agent(environment, command=clear_sessions_command(), env=KIMI_ENV) + await clear_step_tests(environment) + if self._powercontext and self._relay is not None: + await self.exec_as_agent(environment, command=start_relay_command(*self._relay)) + try: + await self.exec_as_agent( + environment, command=run_command(instruction), env={**KIMI_ENV, self._provider_key: key} + ) + finally: + # Kimi Code records each model request's usage in its session. Copying it after a timed-out session too + # keeps the usage of the requests that session made. + with contextlib.suppress(Exception): + await self.exec_as_agent(environment, command=copy_session_logs_command()) + + @override + def populate_context_post_run(self, context: AgentContext) -> None: + usage = session_usage(self.logs_dir / SESSION_LOGS) + if usage is not None: + context.n_input_tokens, context.n_cache_tokens, context.n_output_tokens = usage + + +def _provider(model_name: str | None) -> str: + provider, separator, model = (model_name or "").partition("/") + if not separator or not model or provider not in PROVIDERS: + raise ValueError( # noqa: TRY003 + f"The Kimi Code agent takes a model named provider/model from {', '.join(PROVIDERS)}, not {model_name!r}" + ) + return provider + + +def kimi_config(model_name: str | None, reasoning_effort: str | None) -> str: + """Return Kimi Code's ``config.toml`` for the run's model, the same in both arms.""" + + provider = _provider(model_name) + model = (model_name or "").partition("/")[2] + base_url, key = PROVIDERS[provider] + capabilities = ["thinking", "tool_use"] if reasoning_effort else ["tool_use"] + lines = [ + 'default_model = "harbor"', + # No usage data goes to Moonshot, and no extra model request titles each session. + "telemetry = false", + "auto_session_title = false", + "", + f"[providers.{provider}]", + 'type = "openai"', + f"base_url = {json.dumps(base_url)}", + f"api_key_env = {json.dumps(key)}", + "", + "[models.harbor]", + f"provider = {json.dumps(provider)}", + f"model = {json.dumps(model)}", + f"max_context_size = {MAX_CONTEXT_SIZE}", + f"capabilities = {json.dumps(capabilities)}", + ] + if reasoning_effort: + # Kimi Code takes the effort from those a model declares, and sends it as reasoning_effort. + lines += [ + f"support_efforts = {json.dumps([reasoning_effort])}", + "", + "[thinking]", + f"effort = {json.dumps(reasoning_effort)}", + ] + return "\n".join(lines) + "\n" + + +def plugin_endpoint(server_url: str) -> tuple[str, tuple[str, int] | None]: + """Return the Server URL for the plugin, and the Server address a relay must forward to, if the plugin needs one. + + The plugin takes the Server as an origin, over HTTPS anywhere and over plain HTTP only on loopback, while a + container reaches a Server on its host at an address such as ``host-gateway``. Another URL would leave the plugin + inactive in every ON run, so it is rejected. + """ + + parsed = urlsplit(server_url) + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or parsed.path not in ("", "/") + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + raise ValueError(f"The Kimi Code plugin takes the Server as an HTTP or HTTPS origin, not {server_url!r}") # noqa: TRY003 + if parsed.scheme == "http" and not _loopback(parsed.hostname): + return f"http://127.0.0.1:{RELAY_PORT}", (parsed.hostname or "", parsed.port or 80) + return server_url.rstrip("/"), None + + +def _loopback(host: str | None) -> bool: + if host == "localhost": + return True + try: + return ipaddress.ip_address(host or "").is_loopback + except ValueError: + return False + + +def write_config_command(config: str) -> str: + return f'home={KIMI_HOME}; mkdir -p "$home" && printf %s {shlex.quote(config)} > "$home/config.toml"' + + +def install_plugin_command(server_url: str) -> str: + """Install the mounted plugin where ``powercontext setup kimi-code`` does, configured for the arm's Scope. + + Kimi Code installs plugins only from its interactive interface, so the command writes the copy and the registry + entry that setup writes. + """ + + return ( + ". ~/.nvm/nvm.sh; set -eu; " + f'home={KIMI_HOME}; plugin="$home/plugins/managed/powercontext"; ' + 'mkdir -p "$home/plugins/managed"; rm -rf "$plugin"; ' + f'cp -R {REMOTE_PLUGIN} "$plugin"; ' + f"printf 'powercontext-kimi-code-v1\\n' > \"$plugin/{OWNER_MARKER}\"; " + # Setup enables the token when the variable is set, as it is in the ON arm against an authenticated Server. + f'if [ -n "${{{KIMI_TOKEN}+set}}" ]; then use_token=true; else use_token=false; fi; ' + f'node -e {shlex.quote(CONFIGURE_PLUGIN)} "$home" "$plugin" {REMOTE_PLUGIN} {shlex.quote(server_url)} ' + f'"${{{KIMI_SCOPE}:?}}" "$use_token"' + ) + + +def start_relay_command(host: str, port: int) -> str: + """Start the relay to the Server unless it is already listening, and fail when it does not start.""" + + listening = f"(exec 3<>/dev/tcp/127.0.0.1/{RELAY_PORT}) 2>/dev/null" + return ( + ". ~/.nvm/nvm.sh; " + f"if ! {listening}; then " + f"setsid -f node -e {shlex.quote(RELAY)} {RELAY_PORT} {shlex.quote(host)} {port} >/dev/null 2>&1 &2; exit 1; }}" + ) + + +def clear_sessions_command() -> str: + """Remove everything an earlier session left in Kimi Code's home. + + Besides the sessions themselves, Kimi Code keeps the files a session edited, a search cache, and logs there, and the + plugin keeps its observations. The command then asks Kimi Code for its sessions and fails if any remain, so sessions + stored elsewhere stop the run instead of leaving an earlier session readable. + """ + + kept = " ".join(f"! -name {name}" for name in KIMI_HOME_KEPT) + return ( + "if [ -s ~/.nvm/nvm.sh ]; then . ~/.nvm/nvm.sh; fi; " + f"home={KIMI_HOME}; " + 'mkdir -p "$home" || exit 1; ' + # The trailing /. makes find descend into the directory when it is a symlink. + f'find "$home"/. -mindepth 1 -maxdepth 1 {kept} -exec rm -rf {{}} + || exit 1; ' + "sessions=$(kimi session list --all --archived --json) || exit 1; " + # Kimi Code prints an empty JSON list when it has no sessions. + 'case "$(printf %s "$sessions" | tr -d \'[:space:][]\')" in ' + "'') ;; " + "*) echo 'Kimi Code still lists an earlier session after clearing its home' >&2; exit 1 ;; " + "esac" + ) + + +def run_command(instruction: str) -> str: + return ( + ". ~/.nvm/nvm.sh; " + f"kimi -p {shlex.quote(instruction)} --output-format stream-json &1 " + f"| stdbuf -oL tee {AGENT_LOGS}/{OUTPUT_FILENAME}" + ) + + +def copy_session_logs_command() -> str: + """Copy the session's event logs, one per agent, to the agent logs that Harbor keeps for each step.""" + + return ( + f'home={KIMI_HOME}; target={AGENT_LOGS}/{SESSION_LOGS}; mkdir -p "$target"; ' + 'cd "$home/sessions" && find . -name wire.jsonl | while IFS= read -r log; do ' + 'mkdir -p "$target/${log%/*}" && cp "$log" "$target/$log"; done' + ) + + +def session_usage(directory: Path) -> tuple[int, int, int] | None: + """Sum the usage Kimi Code recorded for the session's model requests, over its main agent and any subagents. + + Kimi Code records each request once, in the event log of the agent that made it. Returns the input tokens, + including cached ones, the cached input tokens, and the output tokens, or nothing when no request was recorded. + """ + + input_tokens = cached_tokens = output_tokens = 0 + recorded = False + for log in sorted(directory.rglob("wire.jsonl")): + # Kimi Code ends each record with LF and leaves U+2028 and the other separators unescaped inside a string. + for line in log.read_text(encoding="utf-8", errors="replace").split("\n"): + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(event, dict) or event.get("type") != "usage.record": + continue + usage = event.get("usage") + if not isinstance(usage, dict): + continue + counts = { + name: usage.get(name, 0) for name in ("inputOther", "inputCacheRead", "inputCacheCreation", "output") + } + if not all(isinstance(value, int) and not isinstance(value, bool) for value in counts.values()): + continue + recorded = True + input_tokens += counts["inputOther"] + counts["inputCacheRead"] + counts["inputCacheCreation"] + cached_tokens += counts["inputCacheRead"] + output_tokens += counts["output"] + return (input_tokens, cached_tokens, output_tokens) if recorded else None diff --git a/e2e/bub/src/powercontext_e2e/hosts.py b/e2e/bub/src/powercontext_e2e/hosts.py index ae1748bd12..1afd5a4ce8 100644 --- a/e2e/bub/src/powercontext_e2e/hosts.py +++ b/e2e/bub/src/powercontext_e2e/hosts.py @@ -20,7 +20,7 @@ from dataclasses import dataclass from os import environ from pathlib import Path -from typing import Any, Protocol +from typing import Any, Protocol, override from harbor.models.trial.config import AgentConfig, ServiceVolumeConfig @@ -28,6 +28,7 @@ from .harbor_agent import BUB_ACP_SERVER_VERSION, BUB_VERSION, REMOTE_CODEX_AUTH, REMOTE_SOURCE from .harbor_claude_code import CLAUDE_CODE_VERSION from .harbor_codex import CODEX_VERSION +from .harbor_kimi_code import KIMI_CODE_VERSION, KIMI_TOKEN, plugin_endpoint from .harbor_opencode import OPENCODE_VERSION from .harbor_pi import PI_VERSION from .settings import ( @@ -152,10 +153,10 @@ class PluginHost: """Run a host through Harbor's own agent for it, with the host's PowerContext plugin only in the ON arm. Only the ON arm installs the plugin and receives the plugin's native ``*`` settings, with - ``SCOPE_ID`` bound to the arm's Scope and ``AUTHORIZATION`` carrying the harness - Client's token. ``SERVER_URL`` names the Server as the agent container reaches it. The harness - selects the model with ``MODEL`` and passes the reasoning effort from - ``REASONING_EFFORT`` explicitly. + ``SCOPE_ID`` bound to the arm's Scope and the harness Client's token in the plugin's credential + setting, ``AUTHORIZATION`` unless the host says otherwise. ``SERVER_URL`` names the + Server as the agent container reaches it. The harness selects the model with ``MODEL`` and passes + the reasoning effort from ``REASONING_EFFORT`` explicitly. """ name: str @@ -201,9 +202,7 @@ def agent_config( if scope_id is not None: env = {**self._plugin_environment(), f"{self.plugin_prefix}SCOPE_ID": scope_id} if (token := server_api_token()) is not None: - # Each plugin sends this value as its Authorization header. - authorization = f"{self.plugin_prefix}AUTHORIZATION" - env[authorization] = agent_secret(authorization, f"Bearer {token}") + env.update(self.credential_environment(token)) return AgentConfig( import_path=self.agent_import_path, model_name=self.agent_model(), @@ -215,6 +214,13 @@ def agent_config( }, ) + def credential_environment(self, token: str) -> dict[str, str]: + """Return the ON arm's setting that gives the plugin the harness Client's token.""" + + # Each plugin sends this value as its Authorization header. + authorization = f"{self.plugin_prefix}AUTHORIZATION" + return {authorization: agent_secret(authorization, f"Bearer {token}")} + @property def _server_url_setting(self) -> str: return f"{self.plugin_prefix}SERVER_URL" @@ -276,6 +282,48 @@ def _plugin_environment(self) -> dict[str, str]: ) +class KimiCodeHost(PluginHost): + """Run Kimi Code, whose plugin takes the Server token without the ``Bearer`` scheme. + + The plugin reads its Server URL and Scope only from the configuration that ``powercontext setup kimi-code`` + writes, so the ON arm's agent writes ``SCOPE_ID`` and a URL for ``SERVER_URL`` + there. + """ + + @override + def agent_config( + self, + task: E2ETask, + *, + scope_id: str | None, + invocation_scopes: tuple[str, ...] | None, + ) -> AgentConfig: + config = super().agent_config(task, scope_id=scope_id, invocation_scopes=invocation_scopes) + # Reject a Server URL the plugin cannot use before a trial starts, rather than in every ON run. + plugin_endpoint(config.kwargs["server_url"]) + return config + + @override + def credential_environment(self, token: str) -> dict[str, str]: + # The plugin's hook and Kimi Code's MCP connection both read the raw token and send it as a Bearer token. + return {KIMI_TOKEN: agent_secret(KIMI_TOKEN, token)} + + +# Harbor 0.16 has no agent for Kimi Code, so the harness runs it with its own. The agent passes the key of the +# model's provider, such as OPENROUTER_API_KEY. +KIMI_CODE = KimiCodeHost( + name="kimi-code", + version=KIMI_CODE_VERSION, + agent_import_path="powercontext_e2e.harbor_kimi_code:PowerContextKimiCodeAgent", + plugin_prefix="POWERCONTEXT_KIMI_", + setting_prefix="POWERCONTEXT_E2E_KIMI_CODE_", + plugin_paths=tuple( + f"integrations/kimi-code/plugins/powercontext/{name}" + for name in ("kimi.plugin.json", "hooks", "scripts", "shared", "skills") + ), +) + + def _capture_settings(task: E2ETask) -> tuple[bool, int, int]: evaluation = task.evaluation if isinstance(evaluation, MemoryEvaluationSpec): @@ -305,7 +353,7 @@ def read_only_bind(source: Path, target: str) -> ServiceVolumeConfig: } -_HOSTS: dict[str, HostAdapter] = {host.name: host for host in (BubHost(), CODEX, CLAUDE_CODE, OPENCODE, PI)} +_HOSTS: dict[str, HostAdapter] = {host.name: host for host in (BubHost(), CODEX, CLAUDE_CODE, OPENCODE, PI, KIMI_CODE)} def host_adapter(name: str) -> HostAdapter: diff --git a/e2e/bub/tests/test_harbor_job_config.py b/e2e/bub/tests/test_harbor_job_config.py index c82b636534..025cab79ae 100644 --- a/e2e/bub/tests/test_harbor_job_config.py +++ b/e2e/bub/tests/test_harbor_job_config.py @@ -17,12 +17,19 @@ from __future__ import annotations import asyncio +import http.server import json import os import shutil +import socket +import subprocess +import threading +import time +import urllib.request from pathlib import Path from types import SimpleNamespace from typing import NamedTuple +from urllib.parse import urlsplit import pytest from harbor.agents.installed import acp as harbor_acp @@ -37,11 +44,12 @@ from harbor.models.task.config import TaskOS from harbor.utils.env import resolve_env_vars -from powercontext_e2e import harbor_agent +from powercontext_e2e import harbor_agent, harbor_kimi_code from powercontext_e2e.catalog import E2ETask, load_tasks from powercontext_e2e.harbor_agent import PowerContextBubAcpAgent from powercontext_e2e.harbor_claude_code import PowerContextClaudeCodeAgent from powercontext_e2e.harbor_codex import PowerContextCodexAgent +from powercontext_e2e.harbor_kimi_code import PowerContextKimiCodeAgent from powercontext_e2e.harbor_opencode import PowerContextOpenCodeAgent from powercontext_e2e.harbor_pi import PowerContextPiAgent from powercontext_e2e.hosts import host_adapter @@ -270,8 +278,11 @@ class _PluginHost(NamedTuple): name: str model: str server_url: str - insecure_http: str + insecure_http: str | None scope_id: str + # The setting, named like the Server URL's, that carries the Server token, and whether it has the Bearer scheme. + credential: str = "AUTHORIZATION" + bearer: bool = True _PLUGIN_HOSTS = [ @@ -303,6 +314,16 @@ class _PluginHost(NamedTuple): "POWERCONTEXT_PI_ALLOW_INSECURE_HTTP", "POWERCONTEXT_PI_SCOPE_ID", ), + # Kimi Code's plugin has no plain-HTTP consent: it accepts plain HTTP on loopback only, where the harness relays. + _PluginHost( + "kimi-code", + "POWERCONTEXT_E2E_KIMI_CODE_MODEL", + "POWERCONTEXT_KIMI_SERVER_URL", + None, + "POWERCONTEXT_KIMI_SCOPE_ID", + credential="TOKEN", + bearer=False, + ), ] @@ -318,7 +339,8 @@ def test_plugin_host_off_arm_has_nothing_of_powercontext(monkeypatch, tmp_path: # like a host without it. monkeypatch.setenv(host.model, "model-test") monkeypatch.setenv(host.server_url, "http://host-gateway:8000") - monkeypatch.setenv(host.insecure_http, "true") + if host.insecure_http is not None: + monkeypatch.setenv(host.insecure_http, "true") monkeypatch.setenv(host.scope_id, "host-scope") monkeypatch.setenv("POWERCONTEXT_CLIENT_API_TOKEN", "server-token") @@ -330,15 +352,16 @@ def test_plugin_host_off_arm_has_nothing_of_powercontext(monkeypatch, tmp_path: assert _powercontext_mounts(on) assert off_agent.env == {} assert on_agent.env[host.scope_id] == "scope-1" - assert on_agent.env[host.insecure_http] == "true" - authorization = host.server_url.replace("SERVER_URL", "AUTHORIZATION") - assert resolve_env_vars(on_agent.env)[authorization] == "Bearer server-token" + if host.insecure_http is not None: + assert on_agent.env[host.insecure_http] == "true" + credential = host.server_url.replace("SERVER_URL", host.credential) + assert resolve_env_vars(on_agent.env)[credential] == ("Bearer server-token" if host.bearer else "server-token") assert off_agent.import_path == on_agent.import_path assert off_agent.model_name == on_agent.model_name == "model-test" assert off_agent.kwargs == {**on_agent.kwargs, "powercontext": False} -@pytest.mark.parametrize("host", ["bub", "codex", "claude-code", "opencode", "pi"]) +@pytest.mark.parametrize("host", ["bub", "codex", "claude-code", "opencode", "pi", "kimi-code"]) def test_job_files_hold_no_part_of_a_short_server_token(monkeypatch, tmp_path: Path, host: str) -> None: # Harbor writes the job configuration to its job files and keeps the first four and last three characters of a # sensitive value, which is most of a token this short. @@ -693,6 +716,350 @@ def test_pi_message_text_cannot_change_the_run_classification(monkeypatch, tmp_p ) +def _kimi_code_agent( + tmp_path: Path, *, powercontext: bool, server_url: str = "http://host-gateway:8000" +) -> PowerContextKimiCodeAgent: + return PowerContextKimiCodeAgent( + logs_dir=tmp_path / "agent-logs", + model_name="openrouter/model-test", + server_url=server_url, + powercontext=powercontext, + reasoning_effort="medium", + ) + + +def test_kimi_code_installs_the_plugin_only_for_on(tmp_path: Path) -> None: + def install(powercontext: bool) -> list[str]: + environment = _RecordingEnvironment() + asyncio.run(_kimi_code_agent(tmp_path, powercontext=powercontext).install(environment)) + return environment.commands + + off, on = install(False), install(True) + + # Both arms install the same Kimi Code with the same model configuration, and ON then adds the plugin. + assert not [command for command in off if "powercontext" in command] + assert on[: len(off)] == off + assert on[len(off) :] + assert all("plugins/managed/powercontext" in command for command in on[len(off) :]) + + +@pytest.mark.parametrize( + "server_url", ["http://host-gateway:8000/pc", "https://powercontext.test/mcp", "ftp://host-gateway:8000"] +) +def test_kimi_code_job_rejects_a_server_url_the_plugin_cannot_use(monkeypatch, tmp_path: Path, server_url: str) -> None: + # The plugin takes the Server only as an origin and otherwise stays inactive, which each ON run would report as an + # integration failure after spending its trial, so the job fails before its container starts. + monkeypatch.setenv("POWERCONTEXT_E2E_KIMI_CODE_MODEL", "openrouter/model-test") + monkeypatch.setenv("POWERCONTEXT_KIMI_SERVER_URL", server_url) + + with pytest.raises(ValueError, match="origin"): + _paired_config(tmp_path, "kimi-code", "scope-1") + + +def _node_executable() -> str | None: + """Return the Node binary itself, which runs under a test's own HOME and PATH, unlike a version manager's shim.""" + + if (node := shutil.which("node")) is None: + return None + try: + located = subprocess.run( # noqa: S603 - the Node found on PATH, with fixed arguments + [node, "-p", "process.execPath"], capture_output=True, text=True, check=True, timeout=30 + ) + except (OSError, subprocess.SubprocessError): + return None + return located.stdout.strip() or None + + +_NODE = _node_executable() +_needs_node = pytest.mark.skipif(_NODE is None, reason="the plugin installation and the relay run Node, as Kimi Code") + + +@_needs_node +def test_kimi_code_relay_forwards_requests_to_the_server(tmp_path: Path) -> None: + # The plugin reaches a plain-HTTP Server only through the relay, so a relay that does not forward a request whole + # leaves every ON run without PowerContext. + body = os.urandom(300_000) + + class Echo(http.server.BaseHTTPRequestHandler): + def do_POST(self) -> None: + received = self.rfile.read(int(self.headers["Content-Length"])) + self.send_response(200) + self.send_header("Content-Length", str(len(received))) + self.end_headers() + self.wfile.write(received) + + def log_message(self, *args: object) -> None: + pass + + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Echo) + threading.Thread(target=server.serve_forever, daemon=True).start() + with socket.socket() as unused: + unused.bind(("127.0.0.1", 0)) + listen = unused.getsockname()[1] + relay = subprocess.Popen( # noqa: S603 - fixed interpreter and arguments + [_NODE or "node", "-e", harbor_kimi_code.RELAY, str(listen), "127.0.0.1", str(server.server_port)] + ) + try: + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", listen), timeout=1).close() + break + except OSError: + time.sleep(0.05) + request = urllib.request.Request(f"http://127.0.0.1:{listen}/v1/context/prepare", data=body, method="POST") + with urllib.request.urlopen(request, timeout=10) as response: # noqa: S310 - the test's own server + assert response.read() == body + finally: + relay.terminate() + relay.wait(timeout=10) + server.shutdown() + + +def _mounted_kimi_plugin(monkeypatch, tmp_path: Path) -> Path: + """Copy what the ON container mounts of the plugin, and point the agent's installation at the copy.""" + + source = tmp_path / "source" + for path in host_adapter("kimi-code").plugin_paths: + if (_REPOSITORY / path).is_dir(): + shutil.copytree(_REPOSITORY / path, source / path) + else: + (source / path).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_REPOSITORY / path, source / path) + plugin = source / "integrations" / "kimi-code" / "plugins" / "powercontext" + monkeypatch.setattr(harbor_kimi_code, "REMOTE_PLUGIN", str(plugin)) + return plugin + + +def _install_kimi_plugin(tmp_path: Path, plugin_url: str, *, token: str | None) -> Path: + env = {harbor_kimi_code.KIMI_SCOPE: "scope-1", "PATH": f"{Path(_NODE or '').parent}:/usr/bin:/bin"} + if token is not None: + env[harbor_kimi_code.KIMI_TOKEN] = token + environment = _ShellEnvironment(tmp_path / "harness", env=env) + result = asyncio.run(environment.exec(harbor_kimi_code.install_plugin_command(plugin_url))) + assert result.return_code == 0, result.stderr + return environment.home / ".kimi-code" + + +def _kimi_installation(home: Path) -> dict[str, object]: + files: dict[str, object] = {} + for path in sorted((home / "plugins").rglob("*")): + if path.is_file(): + text = path.read_text(encoding="utf-8") + files[path.relative_to(home).as_posix()] = json.loads(text) if path.suffix == ".json" else text + for record in files["plugins/installed.json"]["plugins"]: # type: ignore[index] + # Each installation records its own time, and its source and root inside this test's directories. + for name in ("installedAt", "updatedAt", "originalSource"): + del record[name] + record["root"] = Path(record["root"]).resolve().relative_to(home.resolve()).as_posix() + return files + + +@_needs_node +@pytest.mark.parametrize("token", ["server-token", None], ids=["authenticated", "unauthenticated"]) +def test_kimi_code_on_arm_installs_the_plugin_as_setup_does(monkeypatch, tmp_path: Path, token: str | None) -> None: + # Kimi Code installs plugins only from its interactive interface, so the ON arm writes what `powercontext setup + # kimi-code` writes, from the files the container mounts. The harness itself depends on PowerContext's client only. + from powercontext.cli import kimi_code as kimi_setup + + plugin = _mounted_kimi_plugin(monkeypatch, tmp_path) + plugin_url = f"http://127.0.0.1:{harbor_kimi_code.RELAY_PORT}" + harness = _install_kimi_plugin(tmp_path, plugin_url, token=token) + + monkeypatch.setenv("KIMI_CODE_HOME", str(tmp_path / "setup-home")) + if token is not None: + monkeypatch.setenv(harbor_kimi_code.KIMI_TOKEN, token) + monkeypatch.setattr(kimi_setup, "kimi_executable", lambda: _NODE) + kimi_setup.install_kimi_plugin(source=plugin, server_url=plugin_url, scope_id="scope-1") + + assert _kimi_installation(harness) == _kimi_installation(tmp_path / "setup-home") + + +@_needs_node +@pytest.mark.parametrize("server_url", ["http://host-gateway:8000", "https://powercontext.test"]) +def test_kimi_code_plugin_accepts_the_settings_the_harness_writes(monkeypatch, tmp_path: Path, server_url: str) -> None: + # The plugin accepts plain HTTP only on loopback and otherwise stays inactive, so a container that reaches the + # Server at host-gateway must give it the relay's address. + _mounted_kimi_plugin(monkeypatch, tmp_path) + plugin_url = harbor_kimi_code.plugin_endpoint(server_url)[0] + home = _install_kimi_plugin(tmp_path, plugin_url, token="server-token") # noqa: S106 - test value + runtime = home / "plugins" / "managed" / "powercontext" / "shared" / "runtime.mjs" + + loaded = subprocess.run( # noqa: S603 - fixed interpreter and arguments + [ + _NODE or "node", + "--input-type=module", + "-e", + "const runtime = await import(process.argv[1]); " + "console.log(JSON.stringify(runtime.loadSettings({ credentials: false })))", + str(runtime), + ], + capture_output=True, + text=True, + check=True, + ) + + settings = json.loads(loaded.stdout) + assert settings["serverUrl"] == plugin_url + assert settings["scopeId"] == "scope-1" + assert settings["useToken"] is True + assert (urlsplit(plugin_url).hostname == "127.0.0.1") is server_url.startswith("http:") + + +def _kimi_environment( + monkeypatch, + tmp_path: Path, + *, + sessions: str = "[]", + logs: dict[str, list[dict[str, object]]] | None = None, + status: int = 0, +) -> tuple[_ShellEnvironment, Path]: + """Run the agent's commands with a stand-in Kimi Code. + + The stand-in records what a session could read when it started, leaves ``logs`` as the session's event logs, one + per agent, and exits with ``status``. + """ + + environment = _ShellEnvironment(tmp_path) + record = tmp_path / "session-start.txt" + session = tmp_path / "session" + for agent_id, events in (logs or {}).items(): + log = session / "wd_workspace_1" / "session_1" / "agents" / agent_id / "wire.jsonl" + log.parent.mkdir(parents=True) + log.write_text("".join(json.dumps(event) + "\n" for event in events)) + bin_dir = tmp_path / "bin" + (bin_dir / "kimi").write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + f" session) printf '%s' '{sessions}' ;;\n" + f' -p) {{ find "$HOME/.kimi-code" -type f; find "{tmp_path / "tests"}" -type f; }} > "{record}"\n' + f' if [ -d "{session}" ]; then\n' + f' mkdir -p "$HOME/.kimi-code/sessions" && cp -R "{session}/." "$HOME/.kimi-code/sessions/"\n' + " fi\n" + f" exit {status} ;;\n" + " *) exit 2 ;;\n" + "esac\n" + ) + (bin_dir / "stdbuf").write_text('#!/bin/sh\nshift\nexec "$@"\n') + for name in ("kimi", "stdbuf"): + (bin_dir / name).chmod(0o755) + (environment.home / ".nvm").mkdir() + (environment.home / ".nvm" / "nvm.sh").write_text(":\n") + # Harbor's Docker environment mounts the agent's logs directory where the container writes its agent logs. + agent_logs = tmp_path / "agent-logs" + agent_logs.mkdir() + monkeypatch.setattr(harbor_kimi_code, "AGENT_LOGS", str(agent_logs)) + monkeypatch.setenv("OPENROUTER_API_KEY", "provider-key") + return environment, record + + +@pytest.mark.parametrize("powercontext", [True, False]) +def test_kimi_code_sessions_start_without_anything_an_earlier_session_left( + monkeypatch, tmp_path: Path, powercontext: bool +) -> None: + # Harbor leaves Kimi Code's home in place between the steps of a trial. Besides the sessions, Kimi Code keeps the + # files a session edited, a search cache, and logs there, which a later session could otherwise read. + environment, record = _kimi_environment(monkeypatch, tmp_path) + home = environment.home / ".kimi-code" + kept = ["config.toml", "plugins/installed.json", "plugins/managed/powercontext/kimi.plugin.json"] + earlier = [ + "sessions/wd_workspace_1/session_1/agents/main/wire.jsonl", + "session_index.jsonl", + "file-history/wd_workspace_1/1@v2", + "cache/query-store/shard-00/0.bin", + "logs/kimi-code.log", + "powercontext/runtime/attempt-1.json", + ] + for relative in kept + earlier: + (home / relative).parent.mkdir(parents=True, exist_ok=True) + (home / relative).write_text("The team chose OceanBase with 12 shards.") + (tmp_path / "tests").mkdir() + (tmp_path / "tests" / "test.sh").write_text("EXPECTED = 12") + agent = _kimi_code_agent(tmp_path, powercontext=powercontext, server_url="https://powercontext.test") + + asyncio.run(agent.run("task", environment, AgentContext())) + + visible = {Path(line).relative_to(home).as_posix() for line in record.read_text().splitlines()} + assert visible == set(kept) + + +@pytest.mark.skipif(os.geteuid() == 0, reason="root can remove files from a read-only directory") +def test_kimi_code_session_does_not_start_when_the_earlier_tests_cannot_be_removed(monkeypatch, tmp_path: Path) -> None: + environment, record = _kimi_environment(monkeypatch, tmp_path) + (tmp_path / "tests" / "locked").mkdir(parents=True) + (tmp_path / "tests" / "locked" / "test.sh").write_text("The team chose OceanBase with 12 shards.") + (tmp_path / "tests" / "locked").chmod(0o555) # the test user cannot remove files from it + try: + with pytest.raises(RuntimeError, match=r"Emptying .* failed"): + asyncio.run(_kimi_code_agent(tmp_path, powercontext=False).run("task", environment, AgentContext())) + finally: + (tmp_path / "tests" / "locked").chmod(0o755) + + assert not record.exists() + + +def test_kimi_code_session_does_not_start_when_earlier_sessions_remain(monkeypatch, tmp_path: Path) -> None: + # Sessions the clear did not reach must stop the arm instead of leaving an earlier session readable. + environment, record = _kimi_environment(monkeypatch, tmp_path, sessions='[{"id": "session_1"}]') + + with pytest.raises(RuntimeError, match="still lists an earlier session"): + asyncio.run(_kimi_code_agent(tmp_path, powercontext=False).run("task", environment, AgentContext())) + + assert not record.exists() + + +def _kimi_usage(agent_id: str, other: int, cached: int, output: int) -> dict[str, object]: + usage = {"inputOther": other, "output": output, "inputCacheRead": cached, "inputCacheCreation": 0} + return {"type": "usage.record", "agentId": agent_id, "usage": usage, "usageScope": "turn"} + + +def test_kimi_code_step_usage_counts_each_request_of_every_agent_once(monkeypatch, tmp_path: Path) -> None: + # Kimi Code writes no usage to its output. It records each request in the event log of the agent that made it, a + # subagent's in the subagent's own log, and repeats the usage on the message the request produced. + first = _kimi_usage("main", 1_000, 9_000, 50) + repeated = {"type": "agent.message.appended", "message": {"meta": {"usage": first["usage"]}}} + logs = { + "main": [first, repeated, _kimi_usage("main", 300, 10_000, 20)], + "agent-0": [_kimi_usage("agent-0", 2_000, 0, 70)], + } + environment, _ = _kimi_environment(monkeypatch, tmp_path, logs=logs) + agent = _kimi_code_agent(tmp_path, powercontext=False) + context = AgentContext() + + asyncio.run(agent.run("task", environment, context)) + agent.populate_context_post_run(context) + + assert (context.n_input_tokens, context.n_cache_tokens, context.n_output_tokens) == (22_300, 19_000, 140) + + +def test_kimi_code_step_that_failed_keeps_the_usage_of_its_requests(monkeypatch, tmp_path: Path) -> None: + # A session that ends with an error, or is stopped by a timeout, has still spent tokens on the requests it made. + logs = {"main": [_kimi_usage("main", 500, 1_500, 40)]} + environment, _ = _kimi_environment(monkeypatch, tmp_path, logs=logs, status=1) + agent = _kimi_code_agent(tmp_path, powercontext=False) + context = AgentContext() + + with pytest.raises(NonZeroAgentExitCodeError): + asyncio.run(agent.run("task", environment, context)) + agent.populate_context_post_run(context) + + assert (context.n_input_tokens, context.n_cache_tokens, context.n_output_tokens) == (2_000, 1_500, 40) + + +def test_kimi_code_step_without_recorded_requests_reports_no_usage(tmp_path: Path) -> None: + context = AgentContext() + + _kimi_code_agent(tmp_path, powercontext=False).populate_context_post_run(context) + + assert (context.n_input_tokens, context.n_cache_tokens, context.n_output_tokens) == (None, None, None) + + +def test_kimi_code_takes_only_a_model_the_harness_can_configure(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="provider/model"): + PowerContextKimiCodeAgent( + logs_dir=tmp_path, model_name="moonshot/kimi-k2", server_url="http://host-gateway:8000", powercontext=False + ) + + @pytest.mark.parametrize("powercontext", [True, False]) def test_bub_sessions_start_without_the_tapes_an_earlier_session_left( monkeypatch, tmp_path: Path, powercontext: bool @@ -850,7 +1217,7 @@ def test_plugin_host_requires_a_model_and_the_server_url_before_any_run(monkeypa require_runtime_models((_PAIRED_TASK,), adapter) -@pytest.mark.parametrize("host", ["bub", "codex", "claude-code", "opencode", "pi"]) +@pytest.mark.parametrize("host", ["bub", "codex", "claude-code", "opencode", "pi", "kimi-code"]) @pytest.mark.parametrize( "manifest", ["paired-tasks/project-decision-continuation.yaml", "tasks/acceptance-01-project-database-decision.yaml"],