diff --git a/CHANGELOG.md b/CHANGELOG.md index 981dcbe..1a48c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ ## Unreleased +- **Kimi Code CLI driver.** New `kimi-cli` driver + (`lib/drivers/kimi-cli.sh`) implements the full interface for + Moonshot AI's Kimi Code CLI: headless mode with + `kimi -p --output-format stream-json`, activity parsing from + stream-json tool calls, fatal/retriable error detection, and + thinking effort support via `KIMI_MODEL_THINKING_EFFORT`. + stream-json carries no usage summary, so cost comes from the + swarmfile `pricing` map. +- **Kimi auth modes.** Kimi agents authenticate via + `"auth": "apikey"` (host `KIMI_API_KEY` forwarded as + `KIMI_MODEL_API_KEY`, which makes the CLI synthesize an in-memory + provider) or `"auth": "oauth"` (read-only mount of the host + `~/.kimi-code` data dir after `kimi login`; each container copies + it to a writable location on startup). Auto-detection when both + are present. A host `KIMI_MODEL_API_KEY` is accepted directly as + the apikey source. +- **Build: `kimi_cli_version` swarmfile field.** Pins the Kimi + Code CLI version installed in the agent image, mirroring + `codex_cli_version`. + ## 0.22.0 — 2026-06-08 - **Build: base the agent image on Debian trixie.** Replaces diff --git a/Dockerfile b/Dockerfile index 948ff8f..3a574a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,6 +52,17 @@ RUN if echo ",$SWARM_AGENTS," | grep -q ",codex-cli,"; then \ && mkdir -p /home/agent/.codex \ && chown agent:agent /home/agent/.codex; \ fi + +# --- Kimi Code CLI --- +ARG KIMI_CLI_VERSION= +# Installs to /usr/local/bin so the agent user finds kimi on PATH +# without the script editing anyone's shell rc. +RUN if echo ",$SWARM_AGENTS," | grep -q ",kimi-cli,"; then \ + curl -fsSL https://code.kimi.com/kimi-code/install.sh -o /tmp/kimi-install.sh \ + && KIMI_INSTALL_DIR=/usr/local KIMI_NO_MODIFY_PATH=1 \ + KIMI_VERSION="$KIMI_CLI_VERSION" bash /tmp/kimi-install.sh \ + && rm /tmp/kimi-install.sh; \ + fi USER agent # Trust mounted bare repos and allow file:// transport for submodules. @@ -60,6 +71,7 @@ RUN git config --global --add safe.directory '*' \ COPY --chmod=755 lib/harness.sh /harness.sh COPY --chmod=755 lib/interactive.sh /interactive.sh +COPY --chmod=644 lib/upstream-clone.sh /upstream-clone.sh COPY --chmod=755 lib/signing.sh /signing.sh COPY --chmod=755 lib/activity-filter.sh /activity-filter.sh COPY --chmod=644 lib/agent-system-prompt.md /agent-system-prompt.md diff --git a/README.md b/README.md index 75f00d3..e5f2e27 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,6 @@ Each driver implements a fixed role interface: | `agent_activity_jq` | jq filter for activity display | Built-in drivers: `claude-code` (default), `gemini-cli`, -`codex-cli`, `fake` (test double). See +`codex-cli`, `kimi-cli`, `fake` (test double). See [USAGE.md](USAGE.md#writing-a-new-driver) for the full interface and guide to writing a new driver. diff --git a/USAGE.md b/USAGE.md index 7d2d380..fc953b6 100644 --- a/USAGE.md +++ b/USAGE.md @@ -42,6 +42,8 @@ Credentials stay as env vars (not in shell history). | `OPENAI_API_KEY` | | OpenAI API key (for Codex CLI driver). | | `CODEX_AUTH_JSON` | `~/.codex/auth.json` | Path to Codex auth file (ChatGPT subscription). | | `GEMINI_API_KEY` | | Google API key (for Gemini CLI driver). | +| `KIMI_API_KEY` | | Moonshot API key (for Kimi Code CLI driver). | +| `KIMI_CODE_HOME` | `~/.kimi-code` | Path to Kimi data dir (OAuth login state). | | `SWARM_CONFIG` | | Path to swarmfile (or place `swarm.json` in repo root). | | `SWARM_TITLE` | | Dashboard title override. | | `SWARM_SKIP_DEP_CHECK` | | Set to `1` to silence dependency version warnings. | @@ -77,13 +79,16 @@ Per-group fields in `swarm.json` `agents` array: - Claude Code: `low`, `medium`, `high`, `max` (Opus only). - Codex CLI: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`. +- Kimi Code CLI: `low`, `medium`, `high`, `xhigh`, `max`, but the set a + model actually accepts is model-dependent — `kimi-for-coding` rejects + `medium` and `xhigh` with a 400; use `low`, `high`, or `max` there. - Gemini CLI: ignored. Top-level fields: `prompt`, `setup`, `max_idle` (default: `3`), `max_retry_wait`, `driver`, `inject_git_rules`, `git_user` (`name`, `email`, `signing_key`), -`claude_code_version`, `codex_cli_version`, `title`, `tag`, -`pricing`, `docker_args`, `post_process`. +`claude_code_version`, `codex_cli_version`, `kimi_cli_version`, +`title`, `tag`, `pricing`, `docker_args`, `post_process`. ### Interactive profiles @@ -603,6 +608,35 @@ then set `"auth": "chatgpt"` in your swarm config: The auth file is bind-mounted read-only into containers. Override the path with `CODEX_AUTH_JSON=/path/to/auth.json`. +### Kimi Code CLI + +| `auth` value | Credential injected | +|---|---| +| `apikey` | `KIMI_API_KEY` via the `KIMI_MODEL_*` env provider | +| `oauth` | Mounts `~/.kimi-code` (after `kimi login` on the host) | +| omit | API key if set + data dir if found | + +The CLI does not read `KIMI_API_KEY` from the shell environment. +The driver forwards it as `KIMI_MODEL_API_KEY` instead, which makes +the CLI synthesize an in-memory provider for the configured model. +With `apikey` auth the swarmfile `model` is used as the API model +id (the part after `/` in aliases like `kimi-code/kimi-for-coding`), +not looked up as a config alias. + +For OAuth auth, run `kimi login` on the host, then set +`"auth": "oauth"` in your swarm config: + +```json +{ + "driver": "kimi-cli", + "agents": [{ "model": "kimi-code/kimi-for-coding", "auth": "oauth" }] +} +``` + +The data dir is bind-mounted read-only; each container copies it to +a writable location on startup. Override the source path with +`KIMI_CODE_HOME=/path/to/kimi-home`. + ### General rules Groups with `api_key` or `auth_token` ignore the `auth` @@ -664,6 +698,7 @@ Built-in drivers: | `claude-code` | `claude` | Yes | | `gemini-cli` | `gemini` | | | `codex-cli` | `codex` | | +| `kimi-cli` | `kimi` | | | `fake` | (none) | Test double for unit testing | Set the driver globally in `swarm.json`: @@ -710,6 +745,20 @@ The value is forwarded to `npm install -g @openai/codex@` inside the image build. Leave the field unset (or empty) to keep the default "latest published release" behavior. +### Pinning Kimi Code CLI version + +By default the Docker image installs the latest Kimi Code CLI. +To pin a specific version, set `kimi_cli_version` in the +swarmfile: + +```json +{ "kimi_cli_version": "0.28.0" } +``` + +The value is passed to the official install script +(`code.kimi.com/kimi-code/install.sh`) inside the image build. +Leave the field unset (or empty) to install the latest release. + ### Writing a new driver Create `lib/drivers/.sh` implementing these functions: diff --git a/dashboard.sh b/dashboard.sh index 69f106a..e4d903b 100755 --- a/dashboard.sh +++ b/dashboard.sh @@ -242,6 +242,7 @@ short_driver() { claude-code) printf 'claude' ;; gemini-cli) printf 'gemini' ;; codex-cli) printf 'codex' ;; + kimi-cli) printf 'kimi' ;; *) printf '%s' "${1:-}" ;; esac } diff --git a/launch.sh b/launch.sh index 17aeb12..6bebab8 100755 --- a/launch.sh +++ b/launch.sh @@ -121,15 +121,17 @@ compute_swarm_agents() { # produces exit-127 on first session -- see harness's `agent exited with code # 127` retry path). build_image() { - local swarm_agents cc_version codex_version + local swarm_agents cc_version codex_version kimi_version swarm_agents=$(compute_swarm_agents "$CONFIG_FILE") cc_version=$(jq -r '.claude_code_version // empty' "$CONFIG_FILE" 2>/dev/null || true) codex_version=$(jq -r '.codex_cli_version // empty' "$CONFIG_FILE" 2>/dev/null || true) + kimi_version=$(jq -r '.kimi_cli_version // empty' "$CONFIG_FILE" 2>/dev/null || true) echo "--- Building agent image (agents: ${swarm_agents}) ---" docker build -t "$IMAGE_NAME" \ --build-arg "SWARM_AGENTS=${swarm_agents}" \ ${cc_version:+--build-arg "CLAUDE_CODE_VERSION=${cc_version}"} \ ${codex_version:+--build-arg "CODEX_CLI_VERSION=${codex_version}"} \ + ${kimi_version:+--build-arg "KIMI_CLI_VERSION=${kimi_version}"} \ -f "$SWARM_DIR/Dockerfile" "$SWARM_DIR" } @@ -137,7 +139,11 @@ create_bare_repo() { local label="${1:-bare repo}" echo "--- Creating ${label} ---" rm_docker_dir "$BARE_REPO" - git clone --bare "$REPO_ROOT" "$BARE_REPO" + # Local clones hardlink packed objects by default. The bare repo is mounted + # through Docker Desktop while the source stays on the host. Keep its + # object storage independent so receive-pack does not depend on inodes + # shared across the bind-mount boundary. + git clone --bare --no-hardlinks "$REPO_ROOT" "$BARE_REPO" git -C "$BARE_REPO" branch agent-work HEAD 2>/dev/null || true git -C "$BARE_REPO" symbolic-ref HEAD refs/heads/agent-work git -C "$BARE_REPO" config core.sharedRepository world diff --git a/lib/drivers/kimi-cli.sh b/lib/drivers/kimi-cli.sh new file mode 100644 index 0000000..71dd47c --- /dev/null +++ b/lib/drivers/kimi-cli.sh @@ -0,0 +1,294 @@ +#!/bin/bash +# shellcheck disable=SC2034 +# Agent driver: Kimi Code CLI +# Implements the role interface for Moonshot AI's Kimi Code CLI. + +# shellcheck source=_common.sh +source "$(dirname "${BASH_SOURCE[0]}")/_common.sh" + +agent_default_model() { echo "kimi-code/kimi-for-coding"; } +agent_name() { echo "Kimi Code CLI"; } +agent_cmd() { echo "kimi"; } + +agent_version() { + local v + v=$(kimi --version 2>/dev/null || echo "unknown") + # `kimi --version` prints a bare version number ("0.28.0"). + echo "${v%% *}" +} + +# Run one agent session. +# Args: [append_system_prompt_file] +agent_run() { + local model="$1" prompt_text="$2" logfile="$3" + local append_file="${4:-}" + + # kimi has no --append-system-prompt flag; inline the instructions. + if [ -n "$append_file" ] && [ -f "$append_file" ]; then + prompt_text="$(cat "$append_file")"$'\n\n'"$prompt_text" + fi + + # With KIMI_MODEL_API_KEY set (apikey auth) the CLI synthesizes an + # in-memory provider from KIMI_MODEL_* and makes it the default + # model; passing -m would fail because the swarmfile alias + # is not in the container's config.toml. KIMI_MODEL_NAME is the + # model id sent to the API, so strip the provider prefix from + # aliases like kimi-code/kimi-for-coding. + local model_args=() + if [ -n "${KIMI_MODEL_API_KEY:-}" ]; then + export KIMI_MODEL_NAME="${KIMI_MODEL_NAME:-${model##*/}}" + else + model_args=(-m "$model") + fi + + # No --yolo here: kimi rejects --prompt combined with --yolo, + # --auto, or --plan. Headless mode already runs under the auto + # permission policy, so there is nothing to opt into. + # + # _run_reaped puts kimi in its own process group and SIGKILLs + # the group after kimi exits, so surviving children can't keep + # the downstream activity-filter pipeline blocked by holding + # stdout. + _run_reaped "$logfile" kimi \ + -p "$prompt_text" \ + --output-format stream-json \ + "${model_args[@]+"${model_args[@]}"}" +} + +# Start Kimi Code CLI's native interactive UI. +# Args: [append_system_prompt_file] +agent_interactive_run() { + local model="$1" prompt_file="${2:-}" append_file="${3:-}" + + if [ -n "$append_file" ] && [ -f "$append_file" ] \ + && [ ! -f /workspace/AGENTS.md ]; then + cp "$append_file" /workspace/AGENTS.md 2>/dev/null || true + mkdir -p /workspace/.git/info + echo "AGENTS.md" >> /workspace/.git/info/exclude + fi + + if [ -n "$prompt_file" ] && [ -f "$prompt_file" ]; then + printf 'Profile prompt is available at %s\n' "$prompt_file" + fi + + local model_args=() + if [ -n "${KIMI_MODEL_API_KEY:-}" ]; then + export KIMI_MODEL_NAME="${KIMI_MODEL_NAME:-${model##*/}}" + else + model_args=(-m "$model") + fi + + kimi --yolo "${model_args[@]+"${model_args[@]}"}" +} + +# Write agent-specific settings and authenticate. +agent_settings() { + local _workspace="$1" + local kimi_home="${HOME}/.kimi-code" + local staged_home="${HOME}/.kimi-code-host" + + # oauth auth mounts the host data dir read-only at the staged + # path. The CLI writes sessions and logs into its data dir, so + # it gets a private writable copy instead of the mount itself. + if [ -d "$staged_home" ] && [ ! -e "$kimi_home" ]; then + cp -r "$staged_home" "$kimi_home" + fi + + # Kimi reads AGENTS.md for project instructions, not + # .claude/CLAUDE.md. Bridge the gap when AGENTS.md is absent. + if [ ! -f "${_workspace}/AGENTS.md" ]; then + local _src="" + [ -f "${_workspace}/.claude/CLAUDE.md" ] \ + && _src="${_workspace}/.claude/CLAUDE.md" + [ -z "$_src" ] && [ -f "${_workspace}/CLAUDE.md" ] \ + && _src="${_workspace}/CLAUDE.md" + if [ -n "$_src" ]; then + cp "$_src" "${_workspace}/AGENTS.md" + mkdir -p "${_workspace}/.git/info" + echo "AGENTS.md" >> "${_workspace}/.git/info/exclude" + fi + fi + + # Kimi reads skills from .agents/skills/, not .claude/skills/. + # Symlink when the Kimi location is absent (Kimi supports + # symlinked skill folders). Only fires when .claude/skills/ + # exists (context=full); slim/none strip it so this is a no-op. + if [ ! -d "${_workspace}/.agents/skills" ] \ + && [ -d "${_workspace}/.claude/skills" ]; then + mkdir -p "${_workspace}/.agents" + ln -s "../.claude/skills" "${_workspace}/.agents/skills" + mkdir -p "${_workspace}/.git/info" + echo ".agents/" >> "${_workspace}/.git/info/exclude" + fi +} + +# Extract stats from a Kimi stream-json log. +# stream-json emits only assistant, tool, and meta objects -- no +# usage or cost summary (verified against kimi-code 0.28.0). Token +# and cost fields stay 0; cost comes from the swarmfile pricing map. +# turns counts assistant messages, the only per-step signal in the +# stream. +agent_extract_stats() { + local logfile="$1" + local turns + turns=$(grep -c '"role"[[:space:]]*:[[:space:]]*"assistant"' \ + "$logfile" 2>/dev/null || true) + turns="${turns:-0}" + printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" \ + "0" "0" "0" "0" "0" "0" "0" "$turns" +} + +# Return the jq program for parsing activity from Kimi stream-json. +# Tool calls ride on assistant messages as tool_calls[]; arguments +# are a JSON string that needs a second fromjson. Tool results come +# as separate role:"tool" messages and are skipped -- the call line +# already carries the useful signal. +agent_activity_jq() { + cat <<'JQ' +def truncate(n): + if length > n then .[:n-3] + "..." else . end; + +def first_line: + split("\n")[0] // .; + +def ts: + now | strftime("%H:%M:%S"); + +def prefix: + "\u001b[33m\(ts) agent[\($id)]"; + +def reset: + "\u001b[0m"; + +fromjson? // empty | +select(.role == "assistant") | +(.tool_calls // [])[] | +(.function.arguments | fromjson? // {}) as $args | +if .function.name == "Bash" then "\(prefix) Shell: " + (($args.command // "") | first_line | truncate(80)) + reset +elif .function.name == "Read" then "\(prefix) Read " + ($args.path // $args.file_path // "") + reset +elif .function.name == "Write" then "\(prefix) Write " + ($args.path // $args.file_path // "") + reset +elif .function.name == "Edit" then "\(prefix) Edit " + ($args.path // $args.file_path // "") + reset +elif .function.name == "Glob" then "\(prefix) Glob " + ($args.pattern // "") + reset +elif .function.name == "Grep" then "\(prefix) Grep " + ($args.pattern // "") + reset +elif .function.name == "WebSearch" then "\(prefix) Search: " + ($args.query // "") + reset +elif .function.name == "FetchURL" then "\(prefix) Fetch " + ($args.url // "") + reset +elif .function.name == "Agent" then "\(prefix) Agent: " + (($args.description // $args.prompt // "") | first_line | truncate(60)) + reset +else "\(prefix) " + (.function.name // "unknown") + reset +end +JQ +} + +# Detect fatal errors in a Kimi session log. +# stream-json has no terminal error object; failures surface on +# stderr while stdout carries no assistant messages. +agent_detect_fatal() { + local logfile="$1" + + if [ -f "${logfile}.err" ]; then + local err_msg + err_msg=$(grep -i 'error\|invalid.*key\|unauthorized' \ + "${logfile}.err" 2>/dev/null \ + | head -1 || true) + if [ -n "$err_msg" ] && \ + ! grep -q '"role"[[:space:]]*:[[:space:]]*"assistant"' \ + "$logfile" 2>/dev/null; then + echo "$err_msg" + fi + fi +} + +# Detect retriable errors. +# Returns non-empty string if the error is retriable, empty if fatal. +# Args: +agent_is_retriable() { + local logfile="$1" + local _rate='429\|rate.limit\|too many requests\|quota\|usage.limit\|hit your.*limit' + local _transient='connection reset\|connection closed\|connection refused\|gateway timeout\|bad gateway\|service unavailable\|\b50[234]\b\|timed out\|temporarily unavailable\|at capacity\|overloaded' + for f in "$logfile" "${logfile}.err"; do + [ -f "$f" ] || continue + grep -qi "$_rate" "$f" 2>/dev/null \ + && echo "rate_limited" && return + grep -qi "$_transient" "$f" 2>/dev/null \ + && echo "transient" && return + done + return 0 +} + +# Map effort to Kimi's thinking-effort override. +# Args: +agent_docker_env() { + local effort="${1:-}" + if [ -n "$effort" ]; then + printf -- '-e\nKIMI_MODEL_THINKING_EFFORT=%s\n' "$effort" + fi +} + +# Resolve auth credentials and emit Docker flags. +# Args: +# Reads host env: KIMI_API_KEY, KIMI_MODEL_API_KEY, KIMI_CODE_HOME +# +# Auth modes: +# oauth — Mount the host Kimi data dir (~/.kimi-code) after +# `kimi login` on the host. +# apikey — Use KIMI_API_KEY via the KIMI_MODEL_* env provider. +# (empty) — Auto-detect: API key if set, data dir if found. +agent_docker_auth() { + local api_key="$1" _auth_token="$2" auth_mode="$3" base_url="$4" + + local label="" + # A host may already export the CLI's own provider variable. + local key="${api_key:-${KIMI_MODEL_API_KEY:-${KIMI_API_KEY:-}}}" + local host_home="${KIMI_CODE_HOME:-${HOME}/.kimi-code}" + + # Use --mount instead of -v so Docker errors out (rather than + # silently creating a directory) if the source path is missing. + # Read-only: agent_settings copies it to a writable + # container-local data dir before the first session. + local _mount_fmt='--mount\ntype=bind,source=%s,target=/home/agent/.kimi-code-host,readonly\n' + + case "${auth_mode}" in + oauth) + if [ -d "$host_home" ]; then + printf -- "$_mount_fmt" "$host_home" + label="oauth" + else + echo "WARNING: auth=oauth but ${host_home} not found" >&2 + fi + ;; + apikey) + if [ -n "$key" ]; then + printf -- '-e\nKIMI_MODEL_API_KEY=%s\n' "$key" + label="key" + fi + ;; + *) + if [ -n "$key" ]; then + printf -- '-e\nKIMI_MODEL_API_KEY=%s\n' "$key" + label="key" + fi + if [ -d "$host_home" ]; then + printf -- "$_mount_fmt" "$host_home" + if [ -n "$label" ]; then label="auto" + else label="oauth"; fi + fi + ;; + esac + + if [ -n "$base_url" ]; then + printf -- '-e\nKIMI_MODEL_BASE_URL=%s\n' "$base_url" + fi + + printf -- '-e\nKIMI_DISABLE_TELEMETRY=1\n' + printf -- '-e\nSWARM_AUTH_MODE=%s\n' "$label" +} + +# Dockerfile fragment to install this agent's CLI. +# KIMI_CLI_VERSION is a Docker build-arg; empty = latest. +agent_install_cmd() { + cat <<'INSTALL' +RUN curl -fsSL https://code.kimi.com/kimi-code/install.sh -o /tmp/kimi-install.sh \ + && KIMI_INSTALL_DIR=/usr/local KIMI_NO_MODIFY_PATH=1 \ + KIMI_VERSION="$KIMI_CLI_VERSION" bash /tmp/kimi-install.sh \ + && rm /tmp/kimi-install.sh +INSTALL +} diff --git a/lib/harness.sh b/lib/harness.sh index a04d52f..e1f9d7e 100755 --- a/lib/harness.sh +++ b/lib/harness.sh @@ -50,7 +50,7 @@ if [ ! -f "$DRIVER_FILE" ]; then echo "ERROR: driver not found: ${DRIVER_FILE}" >&2 exit 1 fi -# shellcheck source=drivers/claude-code.sh +# shellcheck disable=SC1090,SC1091 source "$DRIVER_FILE" # Validate the driver implements all required interface functions. @@ -93,6 +93,9 @@ hlog_pipe() { done } +# shellcheck disable=SC1091 +source /upstream-clone.sh + # Ship unpushed local commits via a scratch worktree. # # Fallback for when the in-place `git pull --rebase && git push` @@ -493,7 +496,7 @@ GIT_USER_NAME="${GIT_USER_NAME:-swarm-agent}" GIT_USER_EMAIL="${GIT_USER_EMAIL:-agent@swarm.local}" git config --global user.name "$GIT_USER_NAME" git config --global user.email "$GIT_USER_EMAIL" -# shellcheck source=signing.sh +# shellcheck disable=SC1091 source /signing.sh configure_git_signing @@ -517,8 +520,7 @@ agent_activity_jq > "$SWARM_JQ_FILTER_FILE" export SWARM_JQ_FILTER_FILE if [ ! -d "/workspace/.git" ]; then - hlog "cloning upstream" - git clone -q /upstream /workspace + swarm_clone_upstream /upstream /workspace hlog hlog_err cd /workspace # Init only submodules whose mirrors were mounted into the @@ -825,7 +827,13 @@ while true; do fi fi - _session_end_push || true + # Do not count an unshippable session as idle/success. The stopped + # container is the last durable copy of its local commits, so fail loudly + # and leave it available for harvest or manual recovery. + if ! _session_end_push; then + hlog_err "session-end push failed; exiting with unshipped commits preserved" + exit 1 + fi if [ "$BEFORE" = "$AFTER" ]; then IDLE_COUNT=$((IDLE_COUNT + 1)) diff --git a/lib/interactive.sh b/lib/interactive.sh index f54956c..6cba2c8 100755 --- a/lib/interactive.sh +++ b/lib/interactive.sh @@ -49,6 +49,9 @@ ilog_err() { "$RED" "$(date +%H:%M:%S)" "$SWARM_INTERACTIVE_PROFILE" "$*" "$RST" } +# shellcheck disable=SC1091 +source /upstream-clone.sh + write_interactive_state() { [ -d /workspace/.git ] || return 0 mkdir -p /workspace/agent_logs @@ -118,8 +121,7 @@ AGENT_CLI_NAME=$(agent_name) export AGENT_CLI_NAME if [ ! -d /workspace/.git ]; then - ilog "cloning upstream" - git clone -q /upstream /workspace + swarm_clone_upstream /upstream /workspace ilog ilog_err cd /workspace git fetch --no-recurse-submodules origin 2>&1 || true diff --git a/lib/upstream-clone.sh b/lib/upstream-clone.sh new file mode 100644 index 0000000..05bc7cd --- /dev/null +++ b/lib/upstream-clone.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Clone through Git's transport instead of copying object files across a +# host bind mount and the container filesystem. Retry transient mount errors +# during bootstrap without consuming the agent session's retry budget. +swarm_clone_upstream() { + local source="$1" destination="$2" + local log_fn="$3" log_err_fn="$4" + local max_attempts=5 attempt=1 delay=1 + + "$log_fn" "cloning upstream" + while true; do + if git clone -q --no-local "$source" "$destination"; then + return 0 + fi + + if [ "$attempt" -ge "$max_attempts" ]; then + "$log_err_fn" \ + "upstream clone failed after ${attempt} attempts" + return 1 + fi + + "$log_err_fn" \ + "upstream clone attempt ${attempt}/${max_attempts} failed;" \ + "retrying in ${delay}s" + sleep "$delay" + attempt=$((attempt + 1)) + delay=$((delay * 2)) + done +} diff --git a/tests/configs/README.md b/tests/configs/README.md index 843da51..606447d 100644 --- a/tests/configs/README.md +++ b/tests/configs/README.md @@ -14,12 +14,13 @@ export OPENROUTER_API_KEY="sk-or-v1-..." export MINIMAX_API_KEY="sk-api-..." export GEMINI_API_KEY="AI..." export OPENAI_API_KEY="sk-..." +export KIMI_API_KEY="sk-..." ``` Verify they're set: ```bash -for v in ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN OPENROUTER_API_KEY MINIMAX_API_KEY GEMINI_API_KEY OPENAI_API_KEY; do +for v in ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN OPENROUTER_API_KEY MINIMAX_API_KEY GEMINI_API_KEY OPENAI_API_KEY KIMI_API_KEY; do printf "%-30s %s\n" "$v" "${!v:+(set)}" done ``` @@ -42,6 +43,10 @@ done | `codex-chatgpt.json` | 2x gpt-5.4 (chatgpt auth) | codex-cli | `~/.codex/auth.json` | | `codex-auth-mixed.json` | gpt-5.4 (chatgpt) + gpt-5.3-codex (apikey) + gpt-5.4 (auto) | codex-cli | `~/.codex/auth.json` + `OPENAI_API_KEY` | | `codex-mixed.json` | Opus + gpt-5.4 + gpt-5.3-codex + gpt-5.2 | mixed | `CLAUDE_CODE_OAUTH_TOKEN` + `OPENAI_API_KEY` | +| `kimi-only.json` | 2x kimi-code/kimi-for-coding | kimi-cli | `KIMI_API_KEY` | +| `kimi-oauth.json` | 2x kimi-code/kimi-for-coding (oauth auth) | kimi-cli | `~/.kimi-code` | +| `kimi-auth-mixed.json` | kimi-for-coding (oauth) + kimi-for-coding (apikey) + kimi-for-coding (auto) | kimi-cli | `~/.kimi-code` + `KIMI_API_KEY` | +| `kimi-mixed.json` | Opus + 2x kimi-code/kimi-for-coding | mixed | `CLAUDE_CODE_OAUTH_TOKEN` + `KIMI_API_KEY` | ## Usage @@ -60,6 +65,10 @@ done ./tests/test.sh --config tests/configs/codex-chatgpt.json ./tests/test.sh --config tests/configs/codex-auth-mixed.json ./tests/test.sh --config tests/configs/codex-mixed.json +./tests/test.sh --config tests/configs/kimi-only.json +./tests/test.sh --config tests/configs/kimi-oauth.json +./tests/test.sh --config tests/configs/kimi-auth-mixed.json +./tests/test.sh --config tests/configs/kimi-mixed.json ``` The test runner injects its own prompt and setup script into the config, diff --git a/tests/configs/kimi-auth-mixed.json b/tests/configs/kimi-auth-mixed.json new file mode 100644 index 0000000..604bef2 --- /dev/null +++ b/tests/configs/kimi-auth-mixed.json @@ -0,0 +1,20 @@ +{ + "prompt": "unused", + "driver": "kimi-cli", + "agents": [ + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "auth": "oauth" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "auth": "apikey" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding" + } + ] +} diff --git a/tests/configs/kimi-mixed.json b/tests/configs/kimi-mixed.json new file mode 100644 index 0000000..fa9dc2b --- /dev/null +++ b/tests/configs/kimi-mixed.json @@ -0,0 +1,22 @@ +{ + "prompt": "unused", + "agents": [ + { + "count": 1, + "model": "claude-opus-4-6", + "driver": "claude-code", + "auth": "oauth" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "driver": "kimi-cli", + "effort": "high" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "driver": "kimi-cli" + } + ] +} diff --git a/tests/configs/kimi-oauth.json b/tests/configs/kimi-oauth.json new file mode 100644 index 0000000..947bc58 --- /dev/null +++ b/tests/configs/kimi-oauth.json @@ -0,0 +1,17 @@ +{ + "prompt": "unused", + "driver": "kimi-cli", + "agents": [ + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "auth": "oauth", + "effort": "high" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "auth": "oauth" + } + ] +} diff --git a/tests/configs/kimi-only.json b/tests/configs/kimi-only.json new file mode 100644 index 0000000..efb7772 --- /dev/null +++ b/tests/configs/kimi-only.json @@ -0,0 +1,16 @@ +{ + "prompt": "unused", + "driver": "kimi-cli", + "agents": [ + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "effort": "high" + }, + { + "count": 1, + "model": "kimi-code/kimi-for-coding", + "effort": "low" + } + ] +} diff --git a/tests/runtime_signal_trap.sh b/tests/runtime_signal_trap.sh index f0e3fec..d25adf4 100755 --- a/tests/runtime_signal_trap.sh +++ b/tests/runtime_signal_trap.sh @@ -79,8 +79,8 @@ check_contains() { } # Build the test image (cached after first run). SWARM_AGENTS=fake -# skips the claude-code / codex / gemini CLI install layers since -# the synthetic driver below replaces every real agent. +# skips the claude-code / codex / gemini / kimi CLI install layers +# since the synthetic driver below replaces every real agent. echo "--- Building ${IMAGE_TAG} (fake driver only) ---" docker build --quiet --tag "$IMAGE_TAG" \ --build-arg SWARM_AGENTS=fake "$REPO_ROOT" >/dev/null diff --git a/tests/test_config.sh b/tests/test_config.sh index 33c3df7..0e9793e 100755 --- a/tests/test_config.sh +++ b/tests/test_config.sh @@ -1228,6 +1228,146 @@ assert_eq "signing key missing from git_user" "" \ assert_eq "git name still works" "bot" \ "$(parse_git_name "$TMPDIR/nosign.json")" +# ============================================================ +echo "" +echo "=== 35. Kimi-only config ===" + +CFG="$TESTS_DIR/configs/kimi-only.json" + +assert_eq "kimi-only count" "2" "$(parse_num_agents "$CFG")" +assert_eq "kimi-only driver" "kimi-cli" "$(jq -r '.driver' "$CFG")" +assert_eq "kimi-only model[0]" "kimi-code/kimi-for-coding" \ + "$(jq -r '.agents[0].model' "$CFG")" +assert_eq "kimi-only effort[0]" "high" "$(jq -r '.agents[0].effort' "$CFG")" +assert_eq "kimi-only effort[1]" "low" "$(jq -r '.agents[1].effort' "$CFG")" + +# Agents inherit top-level driver. +KIMI_AGENTS=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | + (.driver // $dd // "claude-code")' "$CFG") +assert_eq "kimi-only agent inherits driver" "kimi-cli" \ + "$(echo "$KIMI_AGENTS" | sed -n '1p')" + +# ============================================================ +echo "" +echo "=== 36. Kimi OAuth auth config ===" + +CFG="$TESTS_DIR/configs/kimi-oauth.json" + +assert_eq "kimi-oauth count" "2" "$(parse_num_agents "$CFG")" +assert_eq "kimi-oauth driver" "kimi-cli" "$(jq -r '.driver' "$CFG")" +assert_eq "kimi-oauth model[0]" "kimi-code/kimi-for-coding" \ + "$(jq -r '.agents[0].model' "$CFG")" +assert_eq "kimi-oauth auth[0]" "oauth" "$(jq -r '.agents[0].auth' "$CFG")" +assert_eq "kimi-oauth auth[1]" "oauth" "$(jq -r '.agents[1].auth' "$CFG")" + +# All agents inherit oauth auth. +_all_auths=$(jq -r \ + '.agents[] | range(.count // 0) as $i | (.auth // "auto")' \ + "$CFG") +_oauth_count=$(echo "$_all_auths" | grep -c "oauth" || true) +assert_eq "kimi-oauth all auth=oauth" "2" "$_oauth_count" + +# ============================================================ +echo "" +echo "=== 37. Kimi data dir driver-level resolution ===" + +source "$TESTS_DIR/../lib/drivers/kimi-cli.sh" +_fake_kimi_home="$TMPDIR/fake-kimi-home" +mkdir -p "$_fake_kimi_home/oauth" + +# auth=apikey: env provider key, no mount. +AUTH_OUT=$(KIMI_API_KEY="sk-kimi-key" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "apikey" "") +assert_contains "kimi resolve apikey has key" \ + "KIMI_MODEL_API_KEY=sk-kimi-key" "$AUTH_OUT" +assert_contains "kimi resolve apikey label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" +_mount_count=$(echo "$AUTH_OUT" | grep -c -- "--mount" || true) +assert_eq "kimi resolve apikey no mount" "0" "$_mount_count" + +# auth=oauth: mounts the data dir, no key. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "oauth" "") +assert_contains "kimi resolve oauth mounts" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi resolve oauth readonly" "readonly" "$AUTH_OUT" +assert_contains "kimi resolve oauth label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "KIMI_MODEL_API_KEY" || true) +assert_eq "kimi resolve oauth no key" "0" "$_key_count" + +# auth omitted, both available: auto-detect. +AUTH_OUT=$(KIMI_API_KEY="sk-auto-key" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "" "") +assert_contains "kimi resolve auto has key" \ + "KIMI_MODEL_API_KEY=sk-auto-key" "$AUTH_OUT" +assert_contains "kimi resolve auto mounts" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi resolve auto label" "SWARM_AUTH_MODE=auto" "$AUTH_OUT" + +# auth omitted, only data dir: oauth label. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "" "") +assert_contains "kimi resolve oauth-only mount" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi resolve oauth-only label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" + +# KIMI_CODE_HOME pointing at a missing dir: no mount. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="/tmp/nonexistent-kimi-home" \ + agent_docker_auth "" "" "oauth" "" 2>/dev/null) +_mount_count=$(echo "$AUTH_OUT" | grep -c -- "--mount" || true) +assert_eq "kimi resolve missing dir no mount" "0" "$_mount_count" + +# ============================================================ +echo "" +echo "=== 38. Kimi-mixed config ===" + +CFG="$TESTS_DIR/configs/kimi-mixed.json" + +MIXED_COUNT=$(parse_num_agents "$CFG") +assert_eq "kimi-mixed count" "3" "$MIXED_COUNT" + +MIXED_AGENTS=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | + [(.model), (.driver // $dd // "claude-code")] | join("|")' "$CFG") +KA1=$(echo "$MIXED_AGENTS" | sed -n '1p') +KA2=$(echo "$MIXED_AGENTS" | sed -n '2p') +KA3=$(echo "$MIXED_AGENTS" | sed -n '3p') +assert_eq "kimi-mixed agent1 driver" "claude-code" "$(echo "$KA1" | cut -d'|' -f2)" +assert_eq "kimi-mixed agent1 model" "claude-opus-4-6" "$(echo "$KA1" | cut -d'|' -f1)" +assert_eq "kimi-mixed agent2 driver" "kimi-cli" "$(echo "$KA2" | cut -d'|' -f2)" +assert_eq "kimi-mixed agent2 model" "kimi-code/kimi-for-coding" \ + "$(echo "$KA2" | cut -d'|' -f1)" +assert_eq "kimi-mixed agent2 effort" "high" "$(jq -r '.agents[1].effort' "$CFG")" +assert_eq "kimi-mixed agent3 driver" "kimi-cli" "$(echo "$KA3" | cut -d'|' -f2)" +assert_eq "kimi-mixed agent3 model" "kimi-code/kimi-for-coding" \ + "$(echo "$KA3" | cut -d'|' -f1)" +assert_eq "kimi-mixed agent3 effort" "null" "$(jq -r '.agents[2].effort // null' "$CFG")" + +# ============================================================ +echo "" +echo "=== 39. Kimi auth-mixed config ===" + +CFG="$TESTS_DIR/configs/kimi-auth-mixed.json" + +assert_eq "kimi-auth-mixed count" "3" "$(parse_num_agents "$CFG")" +assert_eq "kimi-auth-mixed groups" "3" "$(jq '.agents | length' "$CFG")" + +# Group 1: explicit oauth auth. +assert_eq "kimi-auth-mixed[0] auth" "oauth" "$(jq -r '.agents[0].auth' "$CFG")" +assert_eq "kimi-auth-mixed[0] model" "kimi-code/kimi-for-coding" \ + "$(jq -r '.agents[0].model' "$CFG")" + +# Group 2: explicit apikey auth. +assert_eq "kimi-auth-mixed[1] auth" "apikey" "$(jq -r '.agents[1].auth' "$CFG")" +assert_eq "kimi-auth-mixed[1] model" "kimi-code/kimi-for-coding" \ + "$(jq -r '.agents[1].model' "$CFG")" + +# Group 3: no auth field (auto-detect at runtime). +assert_eq "kimi-auth-mixed[2] auth" "null" "$(jq -r '.agents[2].auth // "null"' "$CFG")" +assert_eq "kimi-auth-mixed[2] model" "kimi-code/kimi-for-coding" \ + "$(jq -r '.agents[2].model' "$CFG")" + +# All inherit kimi-cli driver. +_all_drivers=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | + (.driver // $dd // "claude-code")' "$CFG") +_kimi_count=$(echo "$_all_drivers" | grep -c "kimi-cli" || true) +assert_eq "kimi-auth-mixed all kimi-cli" "3" "$_kimi_count" + # ============================================================ echo "" echo "===============================" diff --git a/tests/test_dashboard.sh b/tests/test_dashboard.sh index af75a8b..801dbd8 100755 --- a/tests/test_dashboard.sh +++ b/tests/test_dashboard.sh @@ -60,6 +60,10 @@ assert_eq "bare string medium" "gpt-4o (m)" "$(format_model gpt-4o assert_eq "claude opus max" "claude-opus-4-6 (M)" "$(format_model claude-opus-4-6 max)" assert_eq "codex xhigh" "gpt-5.4 (x)" "$(format_model gpt-5.4 xhigh)" assert_eq "codex none" "gpt-5.4 (n)" "$(format_model gpt-5.4 none)" +assert_eq "kimi no effort" "kimi-code/kimi-for-coding" \ + "$(format_model kimi-code/kimi-for-coding "")" +assert_eq "kimi with effort" "kimi-code/kimi-for-coding (h)" \ + "$(format_model kimi-code/kimi-for-coding high)" assert_eq "no effort" "claude-opus-4-6" "$(format_model claude-opus-4-6 "")" assert_eq "unknown no effort" "unknown" "$(format_model unknown "")" assert_eq "empty default" "unknown" "$(format_model)" @@ -94,6 +98,7 @@ short_driver() { claude-code) printf 'claude' ;; gemini-cli) printf 'gemini' ;; codex-cli) printf 'codex' ;; + kimi-cli) printf 'kimi' ;; *) printf '%s' "${1:-}" ;; esac } @@ -260,6 +265,7 @@ echo "=== 4. short_driver ===" assert_eq "claude-code → claude" "claude" "$(short_driver claude-code)" assert_eq "gemini-cli → gemini" "gemini" "$(short_driver gemini-cli)" assert_eq "codex-cli → codex" "codex" "$(short_driver codex-cli)" +assert_eq "kimi-cli → kimi" "kimi" "$(short_driver kimi-cli)" assert_eq "fake passthrough" "fake" "$(short_driver fake)" assert_eq "unknown passthrough" "foo" "$(short_driver foo)" assert_eq "empty → empty" "" "$(short_driver "")" @@ -542,6 +548,10 @@ assert_eq "gemini flash no effort" "gemini-3-flash-preview" \ "$(format_model "gemini-3-flash-preview" "")" assert_eq "gemini with effort" "gemini-2.5-pro (h)" \ "$(format_model "gemini-2.5-pro" "high")" +assert_eq "kimi full name" "kimi-code/kimi-for-coding" \ + "$(format_model "kimi-code/kimi-for-coding" "")" +assert_eq "kimi with effort" "kimi-code/kimi-for-coding (l)" \ + "$(format_model "kimi-code/kimi-for-coding" "low")" # ============================================================ echo "" @@ -627,6 +637,31 @@ EOF assert_eq "post-process driver split → 2 drivers" "2" \ "$(detect_multi_drivers "$TMPDIR/pp_driver_split.json")" +cat > "$TMPDIR/mixed_kimi.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "kimi-cli", + "agents": [ + { "count": 1, "model": "kimi-code/kimi-for-coding" }, + { "count": 1, "model": "claude-opus-4-6", "driver": "claude-code" } + ] +} +EOF +assert_eq "kimi + claude override → 2 drivers" "2" \ + "$(detect_multi_drivers "$TMPDIR/mixed_kimi.json")" + +cat > "$TMPDIR/all_kimi.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "kimi-cli", + "agents": [ + { "count": 1, "model": "kimi-code/kimi-for-coding" }, + { "count": 1, "model": "kimi-code/kimi-for-coding" } + ] +} +EOF +assert_eq "all kimi → 1 driver" "1" "$(detect_multi_drivers "$TMPDIR/all_kimi.json")" + # ============================================================ echo "" echo "=== 11. Git info and default title ===" diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index a0a88ac..6e6e21e 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -59,6 +59,8 @@ assert_eq "fake driver exists" "true" \ "$([ -f "$DRIVERS_DIR/fake.sh" ] && echo true || echo false)" assert_eq "codex driver exists" "true" \ "$([ -f "$DRIVERS_DIR/codex-cli.sh" ] && echo true || echo false)" +assert_eq "kimi driver exists" "true" \ + "$([ -f "$DRIVERS_DIR/kimi-cli.sh" ] && echo true || echo false)" assert_eq "_common.sh exists" "true" \ "$([ -f "$DRIVERS_DIR/_common.sh" ] && echo true || echo false)" @@ -415,6 +417,12 @@ for fn in "${_required_fns[@]}"; do "$(type -t "$fn" &>/dev/null && echo true || echo false)" done +source "$DRIVERS_DIR/kimi-cli.sh" +for fn in "${_required_fns[@]}"; do + assert_eq "kimi has $fn" "true" \ + "$(type -t "$fn" &>/dev/null && echo true || echo false)" +done + # ============================================================ echo "" echo "=== 17. Gemini CLI driver — role interface ===" @@ -546,6 +554,9 @@ assert_eq "gemini default model" "gemini-2.5-pro" "$(agent_default_model)" source "$DRIVERS_DIR/codex-cli.sh" assert_eq "codex default model" "gpt-5.4" "$(agent_default_model)" +source "$DRIVERS_DIR/kimi-cli.sh" +assert_eq "kimi default model" "kimi-code/kimi-for-coding" "$(agent_default_model)" + # ============================================================ echo "" echo "=== 22. agent_docker_auth — Claude Code driver ===" @@ -1316,7 +1327,7 @@ assert_eq "_run_reaped is defined" "function" \ # `| stdbuf -oL tee "$logfile"` form is absent. Pin both per # driver. fake.sh is intentionally exempt -- it emits synthetic # JSONL inline and never spawns external children. -for _drv in claude-code codex-cli gemini-cli; do +for _drv in claude-code codex-cli gemini-cli kimi-cli; do assert_eq "$_drv: adopts _run_reaped" "1" \ "$(grep -cE '^[[:space:]]*_run_reaped "\$logfile"' "$DRIVERS_DIR/$_drv.sh")" assert_eq "$_drv: drops bare tee pipe" "0" \ @@ -1555,6 +1566,451 @@ CLI pkill -f 'sleep 98765' 2>/dev/null || true fi +# ============================================================ +echo "" +echo "=== 41. Kimi driver — role interface ===" + +source "$DRIVERS_DIR/kimi-cli.sh" + +assert_eq "kimi name" "Kimi Code CLI" "$(agent_name)" +assert_eq "kimi cmd" "kimi" "$(agent_cmd)" +assert_eq "kimi default" "kimi-code/kimi-for-coding" "$(agent_default_model)" + +KIMI_JQ=$(agent_activity_jq) +assert_not_empty "kimi jq filter" "$KIMI_JQ" +assert_contains "kimi jq has tool_calls" "tool_calls" "$KIMI_JQ" +assert_contains "kimi jq has Bash" "Bash" "$KIMI_JQ" + +KIMI_INSTALL=$(agent_install_cmd) +assert_contains "kimi install uses official script" \ + "https://code.kimi.com/kimi-code/install.sh" "$KIMI_INSTALL" +assert_contains "kimi install targets /usr/local" \ + "KIMI_INSTALL_DIR=/usr/local" "$KIMI_INSTALL" +assert_contains "kimi install leaves shell rc alone" \ + "KIMI_NO_MODIFY_PATH=1" "$KIMI_INSTALL" +assert_contains "kimi install supports version" "KIMI_CLI_VERSION" "$KIMI_INSTALL" + +# The build-arg is threaded into KIMI_VERSION verbatim; the script +# treats an empty value as "latest" (its `[ -n "$KIMI_VERSION" ]` +# guard), so no conditional is needed on the Dockerfile side. +assert_contains "kimi install passes build-arg through" \ + 'KIMI_VERSION="$KIMI_CLI_VERSION"' "$KIMI_INSTALL" + +# Headless runs must not pass --yolo: kimi rejects --prompt combined +# with --yolo/--auto/--plan at startup. +assert_eq "kimi agent_run drops --yolo" "0" \ + "$(awk '/^agent_run\(\) \{/,/^\}/' "$DRIVERS_DIR/kimi-cli.sh" \ + | grep -cE '^[[:space:]]+--yolo([[:space:]]|$)' || true)" + +# The headless command shape depends on auth: with KIMI_MODEL_API_KEY +# the env provider's synthesized model is already the CLI default, so +# passing -m would fail to resolve; without a key +# the alias has to be passed explicitly. +( + unset KIMI_MODEL_API_KEY KIMI_MODEL_NAME + _run_reaped() { shift; printf '%s\n' "$*"; } + KIMI_MODEL_API_KEY="sk-test" agent_run \ + "kimi-code/kimi-for-coding" "p" "$TMPDIR/kimi-shape.log" + printf 'name=%s\n' "${KIMI_MODEL_NAME:-}" +) > "$TMPDIR/kimi-shape-key.txt" +assert_eq "kimi apikey: -m not passed" "0" \ + "$(grep -c -- '-m kimi-code' "$TMPDIR/kimi-shape-key.txt" || true)" +assert_eq "kimi apikey: model name prefix stripped" "name=kimi-for-coding" \ + "$(grep '^name=' "$TMPDIR/kimi-shape-key.txt")" + +( + unset KIMI_MODEL_API_KEY KIMI_MODEL_NAME + _run_reaped() { shift; printf '%s\n' "$*"; } + agent_run "kimi-code/kimi-for-coding" "p" "$TMPDIR/kimi-shape.log" + printf 'name=%s\n' "${KIMI_MODEL_NAME:-unset}" +) > "$TMPDIR/kimi-shape-oauth.txt" +assert_eq "kimi oauth: -m alias passed" "1" \ + "$(grep -c -- '-m kimi-code/kimi-for-coding' \ + "$TMPDIR/kimi-shape-oauth.txt" || true)" +assert_eq "kimi oauth: model name untouched" "name=unset" \ + "$(grep '^name=' "$TMPDIR/kimi-shape-oauth.txt")" + +# ============================================================ +echo "" +echo "=== 42. Kimi driver — agent_settings ===" + +KWORK="$TMPDIR/kimi-workspace" +mkdir -p "$KWORK/.git/info" + +# 42a. Staged oauth mount is copied to a writable data dir. +KHOME1="$TMPDIR/kimi-home1" +mkdir -p "$KHOME1/.kimi-code-host/oauth" +echo "token" > "$KHOME1/.kimi-code-host/oauth/token" +HOME="$KHOME1" agent_settings "$KWORK" +assert_eq "kimi staged dir copied" "token" \ + "$(cat "$KHOME1/.kimi-code/oauth/token")" + +# 42b. An existing data dir is never overwritten by the staged copy. +KHOME2="$TMPDIR/kimi-home2" +mkdir -p "$KHOME2/.kimi-code/oauth" "$KHOME2/.kimi-code-host/oauth" +echo "local" > "$KHOME2/.kimi-code/oauth/token" +echo "host" > "$KHOME2/.kimi-code-host/oauth/token" +HOME="$KHOME2" agent_settings "$KWORK" +assert_eq "kimi existing data dir wins" "local" \ + "$(cat "$KHOME2/.kimi-code/oauth/token")" + +# 42c. No staged mount: no data dir created (env-provider auth +# needs no files on disk). +KHOME3="$TMPDIR/kimi-home3" +mkdir -p "$KHOME3" +HOME="$KHOME3" agent_settings "$KWORK" +assert_eq "kimi no data dir without staged mount" "false" \ + "$([ -e "$KHOME3/.kimi-code" ] && echo true || echo false)" + +# 42d. AGENTS.md bridge: .claude/CLAUDE.md copied when no AGENTS.md. +KWORK_B="$TMPDIR/kimi-bridge" +mkdir -p "$KWORK_B/.claude" "$KWORK_B/.git/info" +echo "# Project rules" > "$KWORK_B/.claude/CLAUDE.md" +HOME="$KHOME3" agent_settings "$KWORK_B" +assert_eq "kimi AGENTS.md bridged" "# Project rules" \ + "$(cat "$KWORK_B/AGENTS.md")" +assert_contains "kimi AGENTS.md in git exclude" "AGENTS.md" \ + "$(cat "$KWORK_B/.git/info/exclude")" + +# 42e. Existing AGENTS.md not overwritten. +KWORK_C="$TMPDIR/kimi-bridge-existing" +mkdir -p "$KWORK_C/.claude" "$KWORK_C/.git/info" +echo "# Kimi rules" > "$KWORK_C/AGENTS.md" +echo "# Claude rules" > "$KWORK_C/.claude/CLAUDE.md" +HOME="$KHOME3" agent_settings "$KWORK_C" +assert_eq "kimi existing AGENTS.md preserved" "# Kimi rules" \ + "$(cat "$KWORK_C/AGENTS.md")" + +# 42f. Skills bridge: .claude/skills/ symlinked to .agents/skills/. +KWORK_D="$TMPDIR/kimi-skills" +mkdir -p "$KWORK_D/.claude/skills/triage" "$KWORK_D/.git/info" +echo "---" > "$KWORK_D/.claude/skills/triage/SKILL.md" +HOME="$KHOME3" agent_settings "$KWORK_D" +assert_eq "kimi skills symlink created" "true" \ + "$([ -L "$KWORK_D/.agents/skills" ] && echo true || echo false)" +assert_eq "kimi skill resolves" "---" \ + "$(cat "$KWORK_D/.agents/skills/triage/SKILL.md")" +assert_contains "kimi .agents/ in git exclude" ".agents/" \ + "$(cat "$KWORK_D/.git/info/exclude")" + +# 42g. AGENTS.md bridge: CLAUDE.md at root used as fallback. +KWORK_E="$TMPDIR/kimi-bridge-root" +mkdir -p "$KWORK_E/.git/info" +echo "# Root rules" > "$KWORK_E/CLAUDE.md" +HOME="$KHOME3" agent_settings "$KWORK_E" +assert_eq "kimi AGENTS.md from root CLAUDE.md" "# Root rules" \ + "$(cat "$KWORK_E/AGENTS.md")" + +# 42h. Priority: .claude/CLAUDE.md wins over root CLAUDE.md. +KWORK_F="$TMPDIR/kimi-priority" +mkdir -p "$KWORK_F/.claude" "$KWORK_F/.git/info" +echo "# inner" > "$KWORK_F/.claude/CLAUDE.md" +echo "# outer" > "$KWORK_F/CLAUDE.md" +HOME="$KHOME3" agent_settings "$KWORK_F" +assert_eq "kimi .claude/CLAUDE.md wins over root" "# inner" \ + "$(cat "$KWORK_F/AGENTS.md")" + +# 42i. AGENTS.md bridge: no CLAUDE.md anywhere, nothing created. +KWORK_G="$TMPDIR/kimi-bridge-none" +mkdir -p "$KWORK_G/.git/info" +HOME="$KHOME3" agent_settings "$KWORK_G" +assert_eq "kimi no AGENTS.md without CLAUDE.md" "false" \ + "$([ -f "$KWORK_G/AGENTS.md" ] && echo true || echo false)" + +# 42j. Skills bridge: existing .agents/skills/ not overwritten. +KWORK_H="$TMPDIR/kimi-skills-existing" +mkdir -p "$KWORK_H/.agents/skills/custom" "$KWORK_H/.claude/skills/other" \ + "$KWORK_H/.git/info" +echo "custom" > "$KWORK_H/.agents/skills/custom/SKILL.md" +HOME="$KHOME3" agent_settings "$KWORK_H" +assert_eq "kimi existing .agents/skills preserved" "custom" \ + "$(cat "$KWORK_H/.agents/skills/custom/SKILL.md")" +assert_eq "kimi .agents/skills not a symlink" "false" \ + "$([ -L "$KWORK_H/.agents/skills" ] && echo true || echo false)" + +# 42k. Full context: .claude/CLAUDE.md and .claude/skills/ both +# present, neither target exists → both bridged. +KWORK_I="$TMPDIR/kimi-full-both" +mkdir -p "$KWORK_I/.claude/skills/triage" "$KWORK_I/.git/info" +echo "# full rules" > "$KWORK_I/.claude/CLAUDE.md" +echo "---" > "$KWORK_I/.claude/skills/triage/SKILL.md" +HOME="$KHOME3" agent_settings "$KWORK_I" +assert_eq "kimi full: AGENTS.md bridged" "# full rules" \ + "$(cat "$KWORK_I/AGENTS.md")" +assert_eq "kimi full: skills symlinked" "true" \ + "$([ -L "$KWORK_I/.agents/skills" ] && echo true || echo false)" + +# 42l. AGENTS.md exists + .claude/skills/ present → only skills +# bridged, AGENTS.md untouched. +KWORK_J="$TMPDIR/kimi-agents-exists-skills" +mkdir -p "$KWORK_J/.claude/skills/build-poc" "$KWORK_J/.git/info" +echo "# own agents" > "$KWORK_J/AGENTS.md" +echo "# claude" > "$KWORK_J/.claude/CLAUDE.md" +echo "---" > "$KWORK_J/.claude/skills/build-poc/SKILL.md" +HOME="$KHOME3" agent_settings "$KWORK_J" +assert_eq "kimi AGENTS.md kept, not overwritten" "# own agents" \ + "$(cat "$KWORK_J/AGENTS.md")" +assert_eq "kimi skills still bridged" "true" \ + "$([ -L "$KWORK_J/.agents/skills" ] && echo true || echo false)" + +# ============================================================ +echo "" +echo "=== 43. Kimi driver — agent_extract_stats ===" + +cat > "$TMPDIR/kimi-session.jsonl" <<'EOF' +{"role":"meta","type":"system.version","version":"0.28.0"} +{"role":"assistant","tool_calls":[{"type":"function","id":"t1","function":{"name":"Bash","arguments":"{\"command\":\"ls\"}"}}]} +{"role":"tool","tool_call_id":"t1","content":"file.ts"} +{"role":"assistant","content":"Done."} +EOF + +KSTATS=$(agent_extract_stats "$TMPDIR/kimi-session.jsonl") +IFS=$'\t' read -r k_cost k_in k_out k_cache_rd k_cache_cr k_dur k_api_ms k_turns <<< "$KSTATS" + +assert_eq "kimi cost is 0 (no usage in stream-json)" "0" "$k_cost" +assert_eq "kimi tok_in" "0" "$k_in" +assert_eq "kimi tok_out" "0" "$k_out" +assert_eq "kimi turns = assistant messages" "2" "$k_turns" + +# Empty log: all zeroes. +: > "$TMPDIR/kimi-empty.jsonl" +KSTATS_EMPTY=$(agent_extract_stats "$TMPDIR/kimi-empty.jsonl") +IFS=$'\t' read -r k_cost k_in k_out k_cache_rd k_cache_cr k_dur k_api_ms k_turns <<< "$KSTATS_EMPTY" +assert_eq "kimi empty cost" "0" "$k_cost" +assert_eq "kimi empty turns" "0" "$k_turns" + +# ============================================================ +echo "" +echo "=== 44. Kimi driver — agent_detect_fatal ===" + +# Fatal: stderr error with no assistant output. +: > "$TMPDIR/kimi-stderr.jsonl" +cat > "$TMPDIR/kimi-stderr.jsonl.err" <<'EOF' +Error: Unauthorized - invalid API key +EOF +KFATAL=$(agent_detect_fatal "$TMPDIR/kimi-stderr.jsonl" 1) +assert_not_empty "kimi stderr error detected" "$KFATAL" +assert_contains "kimi fatal mentions unauthorized" "Unauthorized" "$KFATAL" + +# Not fatal: assistant output present despite stderr noise. +cat > "$TMPDIR/kimi-ok.jsonl" <<'EOF' +{"role":"assistant","content":"Done."} +EOF +cat > "$TMPDIR/kimi-ok.jsonl.err" <<'EOF' +Warning: transient error retried successfully +EOF +KOK=$(agent_detect_fatal "$TMPDIR/kimi-ok.jsonl" 0) +assert_eq "kimi ok not flagged" "" "$KOK" + +# Clean log: no err file at all. +cat > "$TMPDIR/kimi-clean.jsonl" <<'EOF' +{"role":"assistant","content":"Done."} +EOF +KCLEAN=$(agent_detect_fatal "$TMPDIR/kimi-clean.jsonl" 0) +assert_eq "kimi clean not flagged" "" "$KCLEAN" + +# ============================================================ +echo "" +echo "=== 45. Kimi driver — agent_is_retriable ===" + +# Rate limit signalled by the CLI's own retry meta line. +cat > "$TMPDIR/kimi-429.jsonl" <<'EOF' +{"role":"meta","type":"turn.step.retrying","failed_attempt":1,"next_attempt":2,"max_attempts":5,"delay_ms":2000,"error_name":"APIError","error_message":"429 Too many requests","status_code":429} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/kimi-429.jsonl" 1) +assert_not_empty "kimi 429 is retriable" "$RETRY_OUT" + +# Moonshot's plan exhaustion is worded as quota, not rate limiting. +cat > "$TMPDIR/kimi-quota.jsonl" <<'EOF' +{"role":"meta","type":"turn.step.retrying","error_name":"APIError","error_message":"quota exceeded for model kimi-for-coding"} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/kimi-quota.jsonl" 1) +assert_not_empty "kimi quota is retriable" "$RETRY_OUT" + +: > "$TMPDIR/kimi-rate-stderr.jsonl" +cat > "$TMPDIR/kimi-rate-stderr.jsonl.err" <<'EOF' +Error: rate limit exceeded, please retry later +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/kimi-rate-stderr.jsonl" 1) +assert_not_empty "kimi rate limit in stderr is retriable" "$RETRY_OUT" + +cat > "$TMPDIR/kimi-503.jsonl" <<'EOF' +{"role":"meta","type":"turn.step.retrying","error_name":"APIError","error_message":"503 service unavailable","status_code":503} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/kimi-503.jsonl" 1) +assert_not_empty "kimi 503 is retriable" "$RETRY_OUT" + +cat > "$TMPDIR/kimi-auth-err.jsonl" <<'EOF' +EOF +cat > "$TMPDIR/kimi-auth-err.jsonl.err" <<'EOF' +Error: Invalid API key +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/kimi-auth-err.jsonl" 1) +assert_eq "kimi auth error not retriable" "" "$RETRY_OUT" + +# ============================================================ +echo "" +echo "=== 46. Kimi driver — agent_docker_env ===" + +KIMI_ENV=$(agent_docker_env "high") +assert_contains "kimi docker_env effort flag" \ + "KIMI_MODEL_THINKING_EFFORT=high" "$KIMI_ENV" + +KIMI_ENV=$(agent_docker_env "") +assert_eq "kimi docker_env empty effort" "" "$KIMI_ENV" + +# ============================================================ +echo "" +echo "=== 47. Kimi driver — agent_docker_auth ===" + +# API key from per-agent config (explicit apikey mode). +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "sk-kimi-key" "" "apikey" "") +assert_contains "kimi per-agent key" "KIMI_MODEL_API_KEY=sk-kimi-key" "$AUTH_OUT" +assert_contains "kimi per-agent label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" +assert_contains "kimi telemetry disabled" "KIMI_DISABLE_TELEMETRY=1" "$AUTH_OUT" + +# API key from environment (explicit apikey mode). +AUTH_OUT=$(KIMI_API_KEY="sk-env-key" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "" "" "apikey" "") +assert_contains "kimi env key" "KIMI_MODEL_API_KEY=sk-env-key" "$AUTH_OUT" +assert_contains "kimi env key label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" + +# Per-agent overrides env. +AUTH_OUT=$(KIMI_API_KEY="sk-env" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "sk-agent" "" "apikey" "") +assert_contains "kimi per-agent overrides env" \ + "KIMI_MODEL_API_KEY=sk-agent" "$AUTH_OUT" + +# base_url feeds the env provider's endpoint. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "sk-kimi" "" "apikey" "https://api.example.com/v1") +assert_contains "kimi base_url forwarded" \ + "KIMI_MODEL_BASE_URL=https://api.example.com/v1" "$AUTH_OUT" + +# No credentials at all. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "" "" "" "") +assert_contains "kimi no creds has auth mode" "SWARM_AUTH_MODE=" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "KIMI_MODEL_API_KEY" || true) +assert_eq "kimi no creds no key flag" "0" "$_key_count" + +# OAuth mode: mounts the host data dir read-only. +_fake_kimi_home="$TMPDIR/fake-kimi-home" +mkdir -p "$_fake_kimi_home" +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "oauth" "") +assert_contains "kimi oauth mounts data dir" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi oauth mount flag" "--mount" "$AUTH_OUT" +assert_contains "kimi oauth mount readonly" "readonly" "$AUTH_OUT" +assert_contains "kimi oauth label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "KIMI_MODEL_API_KEY" || true) +assert_eq "kimi oauth no api key" "0" "$_key_count" + +# OAuth mode but data dir missing: warns, no mount. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="/nonexistent" \ + agent_docker_auth "" "" "oauth" "" 2>/dev/null) +_mount_count=$(echo "$AUTH_OUT" | grep -c "\-\-mount" || true) +assert_eq "kimi oauth missing no mount" "0" "$_mount_count" + +# Auto-detect: API key + data dir both present. +AUTH_OUT=$(KIMI_API_KEY="sk-both" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "" "") +assert_contains "kimi auto has api key" "KIMI_MODEL_API_KEY=sk-both" "$AUTH_OUT" +assert_contains "kimi auto mounts data dir" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi auto label" "SWARM_AUTH_MODE=auto" "$AUTH_OUT" + +# Auto-detect: only data dir, no key. +AUTH_OUT=$(KIMI_API_KEY="" KIMI_CODE_HOME="$_fake_kimi_home" \ + agent_docker_auth "" "" "" "") +assert_contains "kimi auto oauth-only mount" "$_fake_kimi_home" "$AUTH_OUT" +assert_contains "kimi auto oauth-only label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" + +# ============================================================ +echo "" +echo "=== 48. Kimi driver — activity jq filter via file boundary ===" + +source "$DRIVERS_DIR/kimi-cli.sh" +agent_activity_jq > "$TMPDIR/kimi.jq" + +KIMI_BASH='{"role":"assistant","tool_calls":[{"type":"function","id":"t1","function":{"name":"Bash","arguments":"{\"command\":\"npm test\"}"}}]}' +K_BASH_OUT=$(echo "$KIMI_BASH" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "kimi jq Bash" "Shell:" "$K_BASH_OUT" +assert_contains "kimi jq Bash command" "npm test" "$K_BASH_OUT" + +KIMI_READ='{"role":"assistant","tool_calls":[{"type":"function","id":"t2","function":{"name":"Read","arguments":"{\"path\":\"src/main.ts\"}"}}]}' +K_READ_OUT=$(echo "$KIMI_READ" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "kimi jq Read" "Read " "$K_READ_OUT" +assert_contains "kimi jq Read path" "src/main.ts" "$K_READ_OUT" + +KIMI_EDIT='{"role":"assistant","tool_calls":[{"type":"function","id":"t3","function":{"name":"Edit","arguments":"{\"path\":\"/workspace/src/utils.ts\"}"}}]}' +K_EDIT_OUT=$(echo "$KIMI_EDIT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "kimi jq Edit" "Edit " "$K_EDIT_OUT" +assert_contains "kimi jq Edit path" "/workspace/src/utils.ts" "$K_EDIT_OUT" + +KIMI_SEARCH='{"role":"assistant","tool_calls":[{"type":"function","id":"t4","function":{"name":"WebSearch","arguments":"{\"query\":\"kimi stream-json\"}"}}]}' +K_SEARCH_OUT=$(echo "$KIMI_SEARCH" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "kimi jq WebSearch" "Search:" "$K_SEARCH_OUT" + +KIMI_UNKNOWN='{"role":"assistant","tool_calls":[{"type":"function","id":"t5","function":{"name":"TodoList","arguments":"{}"}}]}' +K_UNK_OUT=$(echo "$KIMI_UNKNOWN" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "kimi jq unknown tool" "TodoList" "$K_UNK_OUT" + +# Tool results, text-only assistant messages, and meta lines are +# silently skipped -- the tool_call line already carries the signal. +KIMI_TOOL='{"role":"tool","tool_call_id":"t1","content":"ok"}' +K_TOOL_OUT=$(echo "$KIMI_TOOL" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "kimi jq tool result silent" "" "$K_TOOL_OUT" + +KIMI_TEXT='{"role":"assistant","content":"All done."}' +K_TEXT_OUT=$(echo "$KIMI_TEXT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "kimi jq text-only assistant silent" "" "$K_TEXT_OUT" + +KIMI_META='{"role":"meta","type":"system.version","version":"0.28.0"}' +K_META_OUT=$(echo "$KIMI_META" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/kimi.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "kimi jq meta line silent" "" "$K_META_OUT" + +# ============================================================ +echo "" +echo "=== 49. Kimi driver in config parsing ===" + +cat > "$TMPDIR/kimi_cfg.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "kimi-cli", + "agents": [ + { "count": 1, "model": "kimi-code/kimi-for-coding" }, + { "count": 1, "model": "gpt-5.4", "driver": "codex-cli" } + ] +} +EOF + +TOP_DRIVER=$(jq -r '.driver // "claude-code"' "$TMPDIR/kimi_cfg.json") +assert_eq "kimi top-level driver" "kimi-cli" "$TOP_DRIVER" + +AGENTS=$(jq -r '.driver as $dd | .agents[] | + (.driver // $dd // "claude-code")' "$TMPDIR/kimi_cfg.json") +LINE1=$(echo "$AGENTS" | sed -n '1p') +LINE2=$(echo "$AGENTS" | sed -n '2p') +assert_eq "kimi agent1 inherits top driver" "kimi-cli" "$LINE1" +assert_eq "kimi agent2 per-agent driver" "codex-cli" "$LINE2" + # ============================================================ echo "" echo "===============================" diff --git a/tests/test_harness.sh b/tests/test_harness.sh index d3f8b82..0326b19 100755 --- a/tests/test_harness.sh +++ b/tests/test_harness.sh @@ -634,6 +634,52 @@ assert_eq "backoff capped at 1800" "1800" "$(simulate_backoff 10)" "$(probe_retriable 'model_not_found: gpt-unknown is not available')" ) +# --- 14c. kimi-cli agent_is_retriable classification --- +# +# Same two-class contract as codex: the harness only enters the +# backoff loop on a non-empty class label, so a misclassified +# transient failure would exit the swarm instead of retrying. +( + # Subshell so sourcing the driver doesn't clobber earlier + # assertions' shell state. + source "$TESTS_DIR/../lib/drivers/kimi-cli.sh" + + probe_retriable() { + local log="$TMPDIR/kimi-retriable-$$-${RANDOM}.log" + printf '%s\n' "$1" > "$log" + agent_is_retriable "$log" 1 + rm -f "$log" + } + + # Rate-limit class. + assert_eq "kimi retriable: 429 response" "rate_limited" \ + "$(probe_retriable 'HTTP 429 Too Many Requests')" + assert_eq "kimi retriable: quota exhaustion" "rate_limited" \ + "$(probe_retriable 'quota exceeded for the current plan')" + assert_eq "kimi retriable: usage limit" "rate_limited" \ + "$(probe_retriable 'You have hit your usage limit')" + + # Transient class. + assert_eq "kimi retriable: 502 bad gateway" "transient" \ + "$(probe_retriable 'HTTP 502 Bad Gateway')" + assert_eq "kimi retriable: 503 service unavailable" "transient" \ + "$(probe_retriable 'HTTP 503 Service Unavailable')" + assert_eq "kimi retriable: connection reset" "transient" \ + "$(probe_retriable 'connection reset by peer')" + assert_eq "kimi retriable: request timeout" "transient" \ + "$(probe_retriable 'request timed out after 60s')" + assert_eq "kimi retriable: at capacity" "transient" \ + "$(probe_retriable 'Selected model is at capacity.')" + assert_eq "kimi retriable: overloaded" "transient" \ + "$(probe_retriable 'The server is overloaded, please retry later')" + + # Genuinely fatal errors must NOT be reclassified as retriable. + assert_eq "kimi non-retriable: auth error" "" \ + "$(probe_retriable 'Unauthorized: invalid API key')" + assert_eq "kimi non-retriable: model not found" "" \ + "$(probe_retriable 'model_not_found: kimi-unknown is not available')" +) + # ============================================================ echo "" echo "=== 12. SSH signing config ===" diff --git a/tests/test_launch.sh b/tests/test_launch.sh index e0a52b6..652387e 100755 --- a/tests/test_launch.sh +++ b/tests/test_launch.sh @@ -1013,6 +1013,27 @@ JSON assert_eq "codex version absent" "" \ "$(jq -r '.codex_cli_version // empty' "$TMPDIR/codex_no_version.json")" +cat > "$TMPDIR/kimi_pinned.json" <<'JSON' +{ + "prompt": "unused", + "kimi_cli_version": "0.28.0", + "agents": [{"count": 1, "driver": "kimi-cli", "model": "kimi-code/kimi-for-coding"}] +} +JSON + +assert_eq "kimi version present" "0.28.0" \ + "$(jq -r '.kimi_cli_version // empty' "$TMPDIR/kimi_pinned.json")" + +cat > "$TMPDIR/kimi_no_version.json" <<'JSON' +{ + "prompt": "unused", + "agents": [{"count": 1, "driver": "kimi-cli", "model": "kimi-code/kimi-for-coding"}] +} +JSON + +assert_eq "kimi version absent" "" \ + "$(jq -r '.kimi_cli_version // empty' "$TMPDIR/kimi_no_version.json")" + # ============================================================ echo "" echo "=== 30. Top-level tag inheritance ===" @@ -1216,7 +1237,7 @@ echo "=== 34. Post-process creates bare repo when missing ===" pp_ensure_bare_repo() { local repo_root="$1" bare_repo="$2" if [ ! -d "$bare_repo" ]; then - git clone --bare "$repo_root" "$bare_repo" 2>/dev/null + git clone --bare --no-hardlinks "$repo_root" "$bare_repo" 2>/dev/null git -C "$bare_repo" branch agent-work HEAD 2>/dev/null || true git -C "$bare_repo" symbolic-ref HEAD refs/heads/agent-work fi @@ -1253,7 +1274,7 @@ rm -rf "$_pp_repo" "$_pp_bare" # ============================================================ echo "" -echo "=== 35. Bare repo is world-writable after creation ===" +echo "=== 35. Bare repo is independent and world-writable after creation ===" # Simulate the bare-repo creation + permission fix from cmd_start. _wr_repo="$TMPDIR/wr-src-repo" @@ -1263,10 +1284,11 @@ git -C "$_wr_repo" \ -c user.name="test" -c user.email="test@test" \ -c commit.gpgsign=false \ commit --allow-empty -m "init" -q +git -C "$_wr_repo" gc --quiet _wr_bare="$TMPDIR/wr-bare-test.git" rm -rf "$_wr_bare" -git clone --bare "$_wr_repo" "$_wr_bare" 2>/dev/null +git clone --bare --no-hardlinks "$_wr_repo" "$_wr_bare" 2>/dev/null git -C "$_wr_bare" branch agent-work HEAD 2>/dev/null || true git -C "$_wr_bare" symbolic-ref HEAD refs/heads/agent-work git -C "$_wr_bare" config core.sharedRepository world @@ -1290,6 +1312,28 @@ _wr_pack_perms=$(stat -c '%A' "$_wr_bare/objects/pack" 2>/dev/null \ _wr_pack_w=$(echo "$_wr_pack_perms" | grep -c 'w.$' || true) assert_eq "bare repo objects/pack/ is world-writable" "1" "$_wr_pack_w" +# The source checkout and bare repo cross a Docker Desktop mount boundary in +# production. Independent packs remove the shared-inode dependency. +_wr_src_pack=$(find "$_wr_repo/.git/objects/pack" -name '*.pack' -print -quit) +_wr_dst_pack=$(find "$_wr_bare/objects/pack" -name '*.pack' -print -quit) +_wr_storage_independent=true +if [ "$_wr_src_pack" -ef "$_wr_dst_pack" ]; then + _wr_storage_independent=false +fi +assert_eq "bare repo pack is not hardlinked to source" "true" \ + "$_wr_storage_independent" + +# Pin the live helper too; the mechanism test above must not drift away from +# the command launch.sh actually uses. +_wr_create_body=$(awk ' + /^create_bare_repo\(\)[[:space:]]*\{/ { p = 1 } + p { print } + p && /^\}[[:space:]]*$/ { exit } +' "$TESTS_DIR/../launch.sh") +assert_eq "live create_bare_repo disables hardlinks" "1" \ + "$(printf '%s\n' "$_wr_create_body" \ + | grep -cF 'git clone --bare --no-hardlinks' || true)" + rm -rf "$_wr_repo" "$_wr_bare" # ============================================================ @@ -1630,6 +1674,14 @@ EOF assert_eq "codex-only top-level driver" "codex-cli" \ "$(compute_swarm_agents "$TMPDIR/csa_codex.json")" +cat > "$TMPDIR/csa_kimi.json" <<'EOF' +{ "prompt": "p.md", + "driver": "kimi-cli", + "agents": [{ "count": 1, "model": "kimi-code/kimi-for-coding" }] } +EOF +assert_eq "kimi-only top-level driver" "kimi-cli" \ + "$(compute_swarm_agents "$TMPDIR/csa_kimi.json")" + cat > "$TMPDIR/csa_mixed.json" <<'EOF' { "prompt": "p.md", "agents": [ @@ -1764,6 +1816,9 @@ assert_eq "build_image forwards CLAUDE_CODE_VERSION build-arg" "1" \ assert_eq "build_image forwards CODEX_CLI_VERSION build-arg" "1" \ "$(printf '%s\n' "$_bi_body" \ | grep -cE -- '--build-arg "CODEX_CLI_VERSION=' || true)" +assert_eq "build_image forwards KIMI_CLI_VERSION build-arg" "1" \ + "$(printf '%s\n' "$_bi_body" \ + | grep -cE -- '--build-arg "KIMI_CLI_VERSION=' || true)" # ============================================================ echo "" diff --git a/tests/test_session_end_push.sh b/tests/test_session_end_push.sh index 73b7dd7..3b4994a 100755 --- a/tests/test_session_end_push.sh +++ b/tests/test_session_end_push.sh @@ -419,6 +419,15 @@ SEP_BODY=$(awk '/^_session_end_push\(\) \{/,/^\}$/' \ assert_contains "_session_end_push updates AFTER on success" \ 'AFTER=$(git rev-parse origin/agent-work)' "$SEP_BODY" +# The ordinary session-end path must fail closed. Otherwise a total push +# failure leaves commits only in the ephemeral container, increments the idle +# counter, and exits 0 as though triage completed successfully. +assert_eq "happy path checks session-end push result" "1" \ + "$(grep -cE '^ if ! _session_end_push; then$' "$HARNESS_FILE")" +assert_eq "happy path exits when commits cannot be shipped" "1" \ + "$(grep -cF 'session-end push failed; exiting with unshipped commits preserved' \ + "$HARNESS_FILE")" + # Pin three fatal `exit 1` sites inside the FATAL_MSG block, # each preceded by a `_session_end_push || true` so commits the # agent landed locally before the fatal error get pushed instead diff --git a/tests/test_upstream_clone.sh b/tests/test_upstream_clone.sh new file mode 100755 index 0000000..74f04c8 --- /dev/null +++ b/tests/test_upstream_clone.sh @@ -0,0 +1,147 @@ +#!/bin/bash +set -euo pipefail + +PASS=0 +FAIL=0 +TMPDIR=$(mktemp -d) +TESTS_DIR="$(cd "$(dirname "$0")" && pwd)" +CLONE_HELPER="$TESTS_DIR/../lib/upstream-clone.sh" +trap 'rm -rf "$TMPDIR"' EXIT + +assert_eq() { + local label="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + echo " PASS: ${label}" + PASS=$((PASS + 1)) + else + echo " FAIL: ${label}" + echo " expected: ${expected}" + echo " actual: ${actual}" + FAIL=$((FAIL + 1)) + fi +} + +# shellcheck disable=SC1090 +source "$CLONE_HELPER" + +INFO_LOG="$TMPDIR/info.log" +ERROR_LOG="$TMPDIR/error.log" + +test_log() { + printf '%s\n' "$*" >> "$INFO_LOG" +} + +test_log_err() { + printf '%s\n' "$*" >> "$ERROR_LOG" +} + +echo "=== 1. Entrypoints use the shared transport clone ===" + +assert_eq "helper uses --no-local" "1" \ + "$(grep -cF 'git clone -q --no-local' "$CLONE_HELPER")" +assert_eq "harness sources clone helper" "1" \ + "$(grep -cF 'source /upstream-clone.sh' \ + "$TESTS_DIR/../lib/harness.sh")" +assert_eq "interactive sources clone helper" "1" \ + "$(grep -cF 'source /upstream-clone.sh' \ + "$TESTS_DIR/../lib/interactive.sh")" +assert_eq "Dockerfile installs clone helper" "1" \ + "$(grep -cF \ + 'COPY --chmod=644 lib/upstream-clone.sh /upstream-clone.sh' \ + "$TESTS_DIR/../Dockerfile")" + +echo "" +echo "=== 2. Transient failures retry and then succeed ===" + +SOURCE_WORK="$TMPDIR/source-work" +SOURCE_BARE="$TMPDIR/source.git" +DESTINATION="$TMPDIR/destination" +FAKE_BIN="$TMPDIR/bin" +GIT_COUNT="$TMPDIR/git-count" +GIT_ARGS="$TMPDIR/git-args" +SLEEP_ARGS="$TMPDIR/sleep-args" +REAL_GIT=$(command -v git) + +mkdir -p "$SOURCE_WORK" "$FAKE_BIN" +git init -q "$SOURCE_WORK" +git -C "$SOURCE_WORK" config user.name test +git -C "$SOURCE_WORK" config user.email test@example.com +git -C "$SOURCE_WORK" config commit.gpgsign false +printf 'bootstrap clone fixture\n' > "$SOURCE_WORK/fixture.txt" +git -C "$SOURCE_WORK" add fixture.txt +git -C "$SOURCE_WORK" commit -q -m "Seed fixture" +git clone -q --bare "$SOURCE_WORK" "$SOURCE_BARE" + +cat > "$FAKE_BIN/git" <<'EOF' +#!/bin/bash +count=0 +if [ -f "$GIT_COUNT" ]; then + count=$(cat "$GIT_COUNT") +fi +count=$((count + 1)) +printf '%s\n' "$count" > "$GIT_COUNT" +printf '%s\n' "$*" >> "$GIT_ARGS" +if [ "$count" -le "$GIT_FAILURES" ]; then + echo "fatal: simulated transient Permission denied" >&2 + exit 128 +fi +exec "$REAL_GIT" "$@" +EOF +chmod +x "$FAKE_BIN/git" + +cat > "$FAKE_BIN/sleep" <<'EOF' +#!/bin/bash +printf '%s\n' "$1" >> "$SLEEP_ARGS" +EOF +chmod +x "$FAKE_BIN/sleep" + +export GIT_COUNT GIT_ARGS SLEEP_ARGS REAL_GIT +export GIT_FAILURES=2 + +PATH="$FAKE_BIN:$PATH" \ + swarm_clone_upstream \ + "$SOURCE_BARE" "$DESTINATION" test_log test_log_err + +assert_eq "clone succeeds on third attempt" "3" \ + "$(cat "$GIT_COUNT")" +assert_eq "every attempt disables local copying" "3" \ + "$(grep -cF 'clone -q --no-local' "$GIT_ARGS")" +assert_eq "backoff sleeps for one and two seconds" $'1\n2' \ + "$(cat "$SLEEP_ARGS")" +assert_eq "cloned repository has expected HEAD" \ + "$(git -C "$SOURCE_BARE" rev-parse HEAD)" \ + "$(git -C "$DESTINATION" rev-parse HEAD)" +assert_eq "two retry errors are logged" "2" \ + "$(grep -cF 'retrying in' "$ERROR_LOG")" + +echo "" +echo "=== 3. Persistent failures stop after five attempts ===" + +rm -rf "$DESTINATION" +: > "$GIT_COUNT" +: > "$GIT_ARGS" +: > "$SLEEP_ARGS" +: > "$ERROR_LOG" +export GIT_FAILURES=99 + +clone_rc=0 +PATH="$FAKE_BIN:$PATH" \ + swarm_clone_upstream \ + "$SOURCE_BARE" "$DESTINATION" test_log test_log_err \ + || clone_rc=$? + +assert_eq "persistent failure returns non-zero" "1" "$clone_rc" +assert_eq "persistent failure attempts five clones" "5" \ + "$(cat "$GIT_COUNT")" +assert_eq "bounded backoff totals four sleeps" $'1\n2\n4\n8' \ + "$(cat "$SLEEP_ARGS")" +assert_eq "terminal failure is logged once" "1" \ + "$(grep -cF 'upstream clone failed after 5 attempts' \ + "$ERROR_LOG")" + +echo "" +echo "===============================" +echo " ${PASS} passed, ${FAIL} failed" +echo "===============================" + +[ "$FAIL" -eq 0 ]