From 644f521cc178d7b57569351addcaa41c738de4c8 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:57:56 +0000 Subject: [PATCH 01/19] Add kimi-cli agent driver --- lib/drivers/kimi-cli.sh | 293 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 lib/drivers/kimi-cli.sh diff --git a/lib/drivers/kimi-cli.sh b/lib/drivers/kimi-cli.sh new file mode 100644 index 0000000..68a41a2 --- /dev/null +++ b/lib/drivers/kimi-cli.sh @@ -0,0 +1,293 @@ +#!/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_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="" + local key="${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 +} From d3190349fa397af86387fd38cb6348dc00679c61 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:43 +0000 Subject: [PATCH 02/19] Add kimi-cli Dockerfile layer and kimi_cli_version pin --- Dockerfile | 11 +++++++++++ launch.sh | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 948ff8f..8ec9011 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. diff --git a/launch.sh b/launch.sh index 17aeb12..2b428ac 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" } From 4778400e0062548822186e026a2a44cb68bffa15 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:47 +0000 Subject: [PATCH 03/19] Map kimi-cli in the dashboard driver column --- dashboard.sh | 1 + 1 file changed, 1 insertion(+) 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 } From 44ed22786e8bf3db4f39161289a13beb80b78e7f Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:52 +0000 Subject: [PATCH 04/19] Document the kimi-cli driver --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 2 +- USAGE.md | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 981dcbe..8ec67c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## 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. +- **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/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: From f2a97583e0ba07878c46e117fd4d738e2638992e Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:59:57 +0000 Subject: [PATCH 05/19] Add kimi-cli driver tests and fixtures --- tests/configs/README.md | 11 +- tests/configs/kimi-auth-mixed.json | 20 ++ tests/configs/kimi-mixed.json | 22 ++ tests/configs/kimi-oauth.json | 17 ++ tests/configs/kimi-only.json | 16 + tests/runtime_signal_trap.sh | 4 +- tests/test_config.sh | 140 +++++++++ tests/test_dashboard.sh | 35 +++ tests/test_drivers.sh | 458 ++++++++++++++++++++++++++++- tests/test_harness.sh | 46 +++ tests/test_launch.sh | 32 ++ 11 files changed, 797 insertions(+), 4 deletions(-) create mode 100644 tests/configs/kimi-auth-mixed.json create mode 100644 tests/configs/kimi-mixed.json create mode 100644 tests/configs/kimi-oauth.json create mode 100644 tests/configs/kimi-only.json 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..fe26d28 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 ===" @@ -1630,6 +1651,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 +1793,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 "" From b335e181cc9976d70e1b6e1d963757ac0d506091 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:19:22 +0000 Subject: [PATCH 06/19] Add upstream clone retry helper --- Dockerfile | 1 + lib/harness.sh | 10 ++- lib/interactive.sh | 6 +- lib/upstream-clone.sh | 30 +++++++ tests/test_upstream_clone.sh | 147 +++++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 lib/upstream-clone.sh create mode 100755 tests/test_upstream_clone.sh diff --git a/Dockerfile b/Dockerfile index 8ec9011..3a574a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,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/lib/harness.sh b/lib/harness.sh index a04d52f..c8cf339 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 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/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 ] From 654422cf1b24e35be87101522cd7be779e9208d6 Mon Sep 17 00:00:00 2001 From: Radu Bahmata Date: Thu, 30 Jul 2026 15:49:11 +0300 Subject: [PATCH 07/19] Fix upstream clone race condition --- Dockerfile | 1 + launch.sh | 6 +- lib/harness.sh | 6 +- lib/interactive.sh | 6 +- lib/upstream-clone.sh | 30 +++++++ tests/test_launch.sh | 29 ++++++- tests/test_upstream_clone.sh | 147 +++++++++++++++++++++++++++++++++++ 7 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 lib/upstream-clone.sh create mode 100755 tests/test_upstream_clone.sh diff --git a/Dockerfile b/Dockerfile index 8ec9011..3a574a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -71,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/launch.sh b/launch.sh index 2b428ac..6bebab8 100755 --- a/launch.sh +++ b/launch.sh @@ -139,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/harness.sh b/lib/harness.sh index a04d52f..c32980d 100755 --- a/lib/harness.sh +++ b/lib/harness.sh @@ -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` @@ -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 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/test_launch.sh b/tests/test_launch.sh index fe26d28..652387e 100755 --- a/tests/test_launch.sh +++ b/tests/test_launch.sh @@ -1237,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 @@ -1274,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" @@ -1284,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 @@ -1311,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" # ============================================================ 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 ] From ece72b282cf0074a6053af74a5bc32a2819be5f3 Mon Sep 17 00:00:00 2001 From: Radu Bahmata Date: Sun, 2 Aug 2026 21:18:27 +0300 Subject: [PATCH 08/19] Do not exit 0 on failed session end push --- lib/harness.sh | 8 +++++++- tests/test_session_end_push.sh | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/harness.sh b/lib/harness.sh index c32980d..3308288 100755 --- a/lib/harness.sh +++ b/lib/harness.sh @@ -827,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/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 From a980ddf9ba49cccaa0b3ab55098755c9d9b9f271 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:12:39 +0000 Subject: [PATCH 09/19] Add qwen-cli agent driver --- lib/drivers/qwen-cli.sh | 359 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 lib/drivers/qwen-cli.sh diff --git a/lib/drivers/qwen-cli.sh b/lib/drivers/qwen-cli.sh new file mode 100644 index 0000000..6779c79 --- /dev/null +++ b/lib/drivers/qwen-cli.sh @@ -0,0 +1,359 @@ +#!/bin/bash +# shellcheck disable=SC2034 +# Agent driver: Qwen Code CLI +# Implements the role interface for Alibaba's Qwen Code CLI. + +# shellcheck source=_common.sh +source "$(dirname "${BASH_SOURCE[0]}")/_common.sh" + +agent_default_model() { echo "qwen3.8-max"; } +agent_name() { echo "Qwen Code CLI"; } +agent_cmd() { echo "qwen"; } + +agent_version() { + local v + v=$(qwen --version 2>/dev/null || echo "unknown") + # `qwen --version` prints a bare version number ("0.21.5"). + echo "${v%% *}" +} + +# Write ~/.qwen/settings.json for API-key auth. +# Mirrors what `bl config agent --agent qwen-code` writes on a +# ModelStudio setup: one openai-protocol provider entry per model, +# key read from the env var named by envKey, openai selected as the +# auth type so no interactive /auth is needed. Without this file +# qwen falls back to its built-in defaults, which point at the +# China (cn-beijing) endpoint and return 401 for international +# accounts. +# Args: +_qwen_write_settings() { + local model="$1" + local model_id="${model##*/}" + mkdir -p "${HOME}/.qwen" + jq -n \ + --arg id "$model_id" \ + --arg base "${QWEN_BASE_URL:-}" \ + --arg effort "${QWEN_REASONING_EFFORT:-}" \ + --arg cw "${QWEN_CONTEXT_WINDOW:-}" ' + { + modelProviders: { openai: [ + ({ id: $id, name: $id, envKey: "DASHSCOPE_API_KEY" } + + (if $base != "" then { baseUrl: $base } else {} end) + + (if $cw != "" then + { generationConfig: + { contextWindowSize: ($cw | tonumber) } } + else {} end)) + ] }, + security: { auth: { selectedType: "openai" } }, + model: ({ name: $id } + + (if $effort != "" then + { reasoningEffort: $effort } + else {} end)) + }' > "${HOME}/.qwen/settings.json" +} + +# Run one agent session. +# Args: [append_system_prompt_file] +agent_run() { + local model="$1" prompt_text="$2" logfile="$3" + local append_file="${4:-}" + + local -a args=( + -p "$prompt_text" + --output-format stream-json + --yolo + ) + + # With DASHSCOPE_API_KEY set (apikey auth) the container has no + # host qwen config, so synthesize settings.json from the env and + # target the provider entry by id (provider prefix stripped). + # Without a key, the mounted host config owns the model aliases + # and the swarmfile alias is passed verbatim. + if [ -n "${DASHSCOPE_API_KEY:-}" ]; then + _qwen_write_settings "$model" + args+=(-m "${model##*/}") + else + args+=(-m "$model") + fi + + # qwen has --append-system-prompt but it takes the text, not a + # file path, so inline the instructions. + if [ -n "$append_file" ] && [ -f "$append_file" ]; then + args+=(--append-system-prompt "$(cat "$append_file")") + fi + + # _run_reaped puts qwen in its own process group and SIGKILLs + # the group after qwen exits, so surviving children can't keep + # the downstream activity-filter pipeline blocked by holding + # stdout. + _run_reaped "$logfile" qwen "${args[@]}" +} + +# Start Qwen Code CLI's native interactive UI. +# Args: [append_system_prompt_file] +agent_interactive_run() { + local model="$1" prompt_file="${2:-}" append_file="${3:-}" + + local -a args=(--yolo) + if [ -n "${DASHSCOPE_API_KEY:-}" ]; then + _qwen_write_settings "$model" + args+=(-m "${model##*/}") + else + args+=(-m "$model") + fi + + if [ -n "$append_file" ] && [ -f "$append_file" ]; then + args+=(--append-system-prompt "$(cat "$append_file")") + fi + + if [ -n "$prompt_file" ] && [ -f "$prompt_file" ]; then + printf 'Profile prompt is available at %s\n' "$prompt_file" + fi + + qwen "${args[@]}" +} + +# Write agent-specific settings and authenticate. +agent_settings() { + local _workspace="$1" + local qwen_home="${HOME}/.qwen" + local staged_home="${HOME}/.qwen-host" + + # oauth auth mounts the host qwen dir read-only at the staged + # path. The CLI writes sessions and logs into its home dir, so + # it gets a private writable copy instead of the mount itself. + if [ -d "$staged_home" ] && [ ! -e "$qwen_home" ]; then + cp -r "$staged_home" "$qwen_home" + fi + + # Qwen reads AGENTS.md/QWEN.md for project instructions, not + # .claude/CLAUDE.md. Bridge the gap when AGENTS.md is absent. + if [ ! -f "${_workspace}/AGENTS.md" ] \ + && [ ! -f "${_workspace}/QWEN.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 + + # Qwen reads project skills from .qwen/skills/, not + # .claude/skills/. Symlink when the Qwen location is absent. + # Only fires when .claude/skills/ exists (context=full); + # slim/none strip it so this is a no-op. + if [ ! -d "${_workspace}/.qwen/skills" ] \ + && [ -d "${_workspace}/.claude/skills" ]; then + mkdir -p "${_workspace}/.qwen" + ln -s "../.claude/skills" "${_workspace}/.qwen/skills" + mkdir -p "${_workspace}/.git/info" + echo ".qwen/skills" >> "${_workspace}/.git/info/exclude" + fi +} + +# Extract stats from a Qwen stream-json log. +# The terminal result object carries usage in the same shape the +# shared JSONL parser expects (input_tokens, output_tokens, +# cache_read_input_tokens), plus duration_ms. It has no cost field +# and may lack num_turns; cost stays 0 (the swarmfile pricing map +# covers it) and turns falls back to counting assistant messages. +agent_extract_stats() { + local logfile="$1" + local stats turns + stats=$(_extract_jsonl_stats "$logfile") + turns="${stats##*$'\t'}" + if [ "${turns:-0}" = "0" ]; then + turns=$(grep -c '"type"[[:space:]]*:[[:space:]]*"assistant"' \ + "$logfile" 2>/dev/null || true) + stats="${stats%$'\t'*} ${turns:-0}" + fi + printf '%s' "$stats" +} + +# Return the jq program for parsing activity from Qwen stream-json. +# The schema mirrors Claude Code's: assistant messages carry +# content blocks of type thinking/text/tool_use, with tool inputs +# as objects. Tool names are the gemini-cli heritage ones +# (run_shell_command, read_file, edit, ...). +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(.type == "assistant") | +.message.content[]? | +if .type == "thinking" then + ((.thinking // "") | first_line | truncate(80)) as $s | + if ($s | length) > 0 then + "\(prefix) Think: " + $s + reset + else empty end +elif .type == "tool_use" then + (.input.file_path // .input.absolute_path // .input.path // "") as $p | + if .name == "run_shell_command" then "\(prefix) Shell: " + ((.input.command // "") | first_line | truncate(80)) + reset + elif .name == "read_file" then "\(prefix) Read " + $p + reset + elif .name == "write_file" then "\(prefix) Write " + $p + reset + elif .name == "edit" then "\(prefix) Edit " + $p + reset + elif .name == "glob" then "\(prefix) Glob " + (.input.pattern // "") + reset + elif .name == "grep_search" then "\(prefix) Grep " + (.input.pattern // "") + reset + elif .name == "web_search" then "\(prefix) Search: " + (.input.query // "") + reset + elif .name == "web_fetch" then "\(prefix) Fetch " + (.input.url // "") + reset + elif .name == "agent" then "\(prefix) Agent: " + ((.input.description // .input.prompt // "") | first_line | truncate(60)) + reset + else "\(prefix) " + (.name // "unknown") + reset + end +else empty +end +JQ +} + +# Detect fatal errors in a Qwen session log. +# A failed run ends with a result object carrying is_error:true; +# startup failures (bad key, unreachable endpoint) may surface only +# on stderr with no assistant messages on stdout. +agent_detect_fatal() { + local logfile="$1" + + local result_line is_err + result_line=$(grep '"type"[[:space:]]*:[[:space:]]*"result"' \ + "$logfile" 2>/dev/null | tail -1 || true) + if [ -n "$result_line" ]; then + is_err=$(echo "$result_line" \ + | jq -r '.is_error // false' 2>/dev/null || true) + if [ "$is_err" = "true" ]; then + echo "$result_line" \ + | jq -r '.result // .error // "unknown error"' \ + 2>/dev/null + return + fi + fi + + 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 '"type"[[: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\|throttling' + 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 Qwen's reasoning-effort settings key. +# The settings writer picks QWEN_REASONING_EFFORT up when it +# synthesizes settings.json (model.reasoningEffort). Qwen's +# unified ladder is low|medium|high|xhigh|max; for DashScope-backed +# qwen models any set tier currently collapses to +# enable_thinking:true. +# Args: +agent_docker_env() { + local effort="${1:-}" + if [ -n "$effort" ]; then + printf -- '-e\nQWEN_REASONING_EFFORT=%s\n' "$effort" + fi +} + +# Resolve auth credentials and emit Docker flags. +# Args: +# Reads host env: QWEN_API_KEY, DASHSCOPE_API_KEY, QWEN_HOME +# +# Auth modes: +# oauth — Mount the host qwen dir (~/.qwen) after `qwen` /auth +# or `bl config agent` on the host. +# apikey — Forward the key as DASHSCOPE_API_KEY; the driver +# synthesizes ~/.qwen/settings.json from the env. +# (empty) — Auto-detect: API key if set, qwen dir if found. +agent_docker_auth() { + local api_key="$1" _auth_token="$2" auth_mode="$3" base_url="$4" + + local label="" + local key="${api_key:-${QWEN_API_KEY:-${DASHSCOPE_API_KEY:-}}}" + local host_home="${QWEN_HOME:-${HOME}/.qwen}" + + # 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 home before the first session. + local _mount_fmt='--mount\ntype=bind,source=%s,target=/home/agent/.qwen-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\nDASHSCOPE_API_KEY=%s\n' "$key" + label="key" + fi + ;; + *) + if [ -n "$key" ]; then + printf -- '-e\nDASHSCOPE_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\nQWEN_BASE_URL=%s\n' "$base_url" + fi + + # Headless runs always pass --yolo; silence the one-line + # no-sandbox startup warning (the container is the sandbox). + printf -- '-e\nQWEN_CODE_SUPPRESS_YOLO_WARNING=1\n' + printf -- '-e\nSWARM_AUTH_MODE=%s\n' "$label" +} + +# Dockerfile fragment to install this agent's CLI. +# QWEN_CLI_VERSION is a Docker build-arg; empty = latest. +agent_install_cmd() { + cat <<'INSTALL' +RUN npm install -g "@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}" \ + && mkdir -p /home/agent/.qwen \ + && chown agent:agent /home/agent/.qwen +INSTALL +} From f6ad31e2a83df188fef6b0d2f3d9cda7c6299341 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:14:50 +0000 Subject: [PATCH 10/19] Add qwen-cli Dockerfile layer and qwen_cli_version pin --- Dockerfile | 12 ++++++++++-- launch.sh | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3a574a9..2863b04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,9 @@ RUN if echo ",$SWARM_AGENTS," | grep -q ",claude-code,"; then \ fi ENV PATH="/home/agent/.local/bin:${PATH}" -# --- Node.js (shared by Gemini CLI and Codex CLI) --- +# --- Node.js (shared by Gemini CLI, Codex CLI and Qwen Code CLI) --- USER root -RUN if echo ",$SWARM_AGENTS," | grep -qE ",(gemini-cli|codex-cli),"; then \ +RUN if echo ",$SWARM_AGENTS," | grep -qE ",(gemini-cli|codex-cli|qwen-cli),"; then \ curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/*; \ @@ -63,6 +63,14 @@ RUN if echo ",$SWARM_AGENTS," | grep -q ",kimi-cli,"; then \ KIMI_VERSION="$KIMI_CLI_VERSION" bash /tmp/kimi-install.sh \ && rm /tmp/kimi-install.sh; \ fi + +# --- Qwen Code CLI --- +ARG QWEN_CLI_VERSION= +RUN if echo ",$SWARM_AGENTS," | grep -q ",qwen-cli,"; then \ + npm install -g "@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}" \ + && mkdir -p /home/agent/.qwen \ + && chown agent:agent /home/agent/.qwen; \ + fi USER agent # Trust mounted bare repos and allow file:// transport for submodules. diff --git a/launch.sh b/launch.sh index 6bebab8..36eebf3 100755 --- a/launch.sh +++ b/launch.sh @@ -121,17 +121,19 @@ 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 kimi_version + local swarm_agents cc_version codex_version kimi_version qwen_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) + qwen_version=$(jq -r '.qwen_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}"} \ + ${qwen_version:+--build-arg "QWEN_CLI_VERSION=${qwen_version}"} \ -f "$SWARM_DIR/Dockerfile" "$SWARM_DIR" } From 8ba651ea7dafe5fff36f5797d1d122db74cb62dd Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:15:43 +0000 Subject: [PATCH 11/19] Map qwen-cli in the dashboard driver column --- dashboard.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/dashboard.sh b/dashboard.sh index e4d903b..016c173 100755 --- a/dashboard.sh +++ b/dashboard.sh @@ -243,6 +243,7 @@ short_driver() { gemini-cli) printf 'gemini' ;; codex-cli) printf 'codex' ;; kimi-cli) printf 'kimi' ;; + qwen-cli) printf 'qwen' ;; *) printf '%s' "${1:-}" ;; esac } From a5678ce2a1ad069d8d579a02c71567b130a57c7c Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:19:15 +0000 Subject: [PATCH 12/19] Document the qwen-cli driver --- CHANGELOG.md | 23 +++++++++++++++++++ README.md | 2 +- USAGE.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ec67c9..9664588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ ## Unreleased +- **Qwen Code CLI driver.** New `qwen-cli` driver + (`lib/drivers/qwen-cli.sh`) implements the full interface for + Alibaba's Qwen Code CLI: headless mode with + `qwen -p --output-format stream-json --yolo`, activity parsing + from stream-json tool calls, fatal/retriable error detection, + and reasoning effort support via `model.reasoningEffort`. + stream-json carries usage but no cost, so cost comes from the + swarmfile `pricing` map. +- **Qwen auth modes.** Qwen agents authenticate via + `"auth": "apikey"` (host `QWEN_API_KEY`/`DASHSCOPE_API_KEY` + forwarded as `DASHSCOPE_API_KEY`; the driver synthesizes + `~/.qwen/settings.json` with an openai-protocol provider entry, + so no login state is needed — the CI-friendly path) or + `"auth": "oauth"` (read-only mount of the host `~/.qwen` home + dir after `qwen` `/auth` or `bl config agent`; each container + copies it to a writable location on startup). Auto-detection + when both are present. Set the per-group `base_url` to match + the account region: ModelStudio keys are region-scoped and the + CLI's built-in default endpoint is cn-beijing, which returns + 401 for international accounts. +- **Build: `qwen_cli_version` swarmfile field.** Pins the Qwen + Code CLI version installed in the agent image, mirroring + `codex_cli_version`. - **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 diff --git a/README.md b/README.md index e5f2e27..6c42836 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`, `kimi-cli`, `fake` (test double). See +`codex-cli`, `kimi-cli`, `qwen-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 fc953b6..7c98bec 100644 --- a/USAGE.md +++ b/USAGE.md @@ -44,6 +44,8 @@ Credentials stay as env vars (not in shell history). | `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). | +| `QWEN_API_KEY` | | ModelStudio/DashScope API key (for Qwen Code CLI driver; `DASHSCOPE_API_KEY` also accepted). | +| `QWEN_HOME` | `~/.qwen` | Path to Qwen home dir (login state and `settings.json`). | | `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. | @@ -82,13 +84,17 @@ Per-group fields in `swarm.json` `agents` array: - 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. +- Qwen Code CLI: `low`, `medium`, `high`, `xhigh`, `max` (written to + `model.reasoningEffort`; on DashScope-backed qwen models any set tier + currently collapses to `enable_thinking: true`). - 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`, `kimi_cli_version`, -`title`, `tag`, `pricing`, `docker_args`, `post_process`. +`qwen_cli_version`, `title`, `tag`, `pricing`, `docker_args`, +`post_process`. ### Interactive profiles @@ -637,6 +643,48 @@ 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`. +### Qwen Code CLI + +| `auth` value | Credential injected | +|---|---| +| `apikey` | `QWEN_API_KEY`/`DASHSCOPE_API_KEY` forwarded as `DASHSCOPE_API_KEY` | +| `oauth` | Mounts `~/.qwen` (after `qwen` + `/auth` or `bl config agent` on the host) | +| omit | API key if set + home dir if found | + +With `apikey` auth the driver synthesizes `~/.qwen/settings.json` +inside the container: one openai-protocol provider entry whose id is +the swarmfile `model` (provider prefix stripped), key read from +`DASHSCOPE_API_KEY`, `security.auth.selectedType: openai` so no +interactive `/auth` is needed. This is the CI-friendly path — no +login state to mount: + +```json +{ + "driver": "qwen-cli", + "agents": [{ + "model": "qwen3.8-max", + "auth": "apikey", + "base_url": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" + }] +} +``` + +Mind the region: ModelStudio keys are region-scoped, and qwen-code's +built-in default endpoint is China (cn-beijing). International +accounts must set `base_url` (as above for a Token Plan key, or +`https://dashscope-intl.aliyuncs.com/compatible-mode/v1` for a +standard pay-as-you-go key) or every request fails with +`401 Invalid API-key`. Optional overrides: `QWEN_CONTEXT_WINDOW` +(adds `generationConfig.contextWindowSize` to the synthesized +provider entry). + +For OAuth auth, configure `~/.qwen` on the host (run `qwen` and use +`/auth`, or `bl config agent --agent qwen-code` for Alibaba Cloud +ModelStudio), then set `"auth": "oauth"` in your swarm config. The +home dir is bind-mounted read-only; each container copies it to a +writable location on startup. Override the source path with +`QWEN_HOME=/path/to/qwen-home`. + ### General rules Groups with `api_key` or `auth_token` ignore the `auth` @@ -699,6 +747,7 @@ Built-in drivers: | `gemini-cli` | `gemini` | | | `codex-cli` | `codex` | | | `kimi-cli` | `kimi` | | +| `qwen-cli` | `qwen` | | | `fake` | (none) | Test double for unit testing | Set the driver globally in `swarm.json`: @@ -759,6 +808,20 @@ 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. +### Pinning Qwen Code CLI version + +By default the Docker image installs the latest Qwen Code CLI. +To pin a specific version, set `qwen_cli_version` in the +swarmfile: + +```json +{ "qwen_cli_version": "0.21.5" } +``` + +The value is forwarded to `npm install -g @qwen-code/qwen-code@` +inside the image build. Leave the field unset (or empty) to +keep the default "latest published release" behavior. + ### Writing a new driver Create `lib/drivers/.sh` implementing these functions: From 8aed016357ccaad239d2bd3f9091897b3f7ad38e Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:33:24 +0000 Subject: [PATCH 13/19] Add qwen-cli driver tests and fixtures --- tests/configs/README.md | 11 +- tests/configs/qwen-auth-mixed.json | 20 ++ tests/configs/qwen-mixed.json | 22 ++ tests/configs/qwen-oauth.json | 17 + tests/configs/qwen-only.json | 16 + tests/test_config.sh | 139 ++++++++ tests/test_dashboard.sh | 31 ++ tests/test_drivers.sh | 530 ++++++++++++++++++++++++++++- tests/test_harness.sh | 44 +++ tests/test_launch.sh | 32 ++ 10 files changed, 860 insertions(+), 2 deletions(-) create mode 100644 tests/configs/qwen-auth-mixed.json create mode 100644 tests/configs/qwen-mixed.json create mode 100644 tests/configs/qwen-oauth.json create mode 100644 tests/configs/qwen-only.json diff --git a/tests/configs/README.md b/tests/configs/README.md index 606447d..13ef5ed 100644 --- a/tests/configs/README.md +++ b/tests/configs/README.md @@ -15,12 +15,13 @@ export MINIMAX_API_KEY="sk-api-..." export GEMINI_API_KEY="AI..." export OPENAI_API_KEY="sk-..." export KIMI_API_KEY="sk-..." +export QWEN_API_KEY="sk-sp-..." ``` 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 KIMI_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 QWEN_API_KEY; do printf "%-30s %s\n" "$v" "${!v:+(set)}" done ``` @@ -47,6 +48,10 @@ done | `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` | +| `qwen-only.json` | 2x qwen3.8-max | qwen-cli | `QWEN_API_KEY` (or `DASHSCOPE_API_KEY`) | +| `qwen-oauth.json` | 2x qwen3.8-max (oauth auth) | qwen-cli | `~/.qwen` | +| `qwen-auth-mixed.json` | qwen3.8-max (oauth) + qwen3.8-max (apikey) + qwen3.8-max (auto) | qwen-cli | `~/.qwen` + `QWEN_API_KEY` | +| `qwen-mixed.json` | Opus + 2x qwen3.8-max | mixed | `CLAUDE_CODE_OAUTH_TOKEN` + `QWEN_API_KEY` | ## Usage @@ -69,6 +74,10 @@ done ./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 +./tests/test.sh --config tests/configs/qwen-only.json +./tests/test.sh --config tests/configs/qwen-oauth.json +./tests/test.sh --config tests/configs/qwen-auth-mixed.json +./tests/test.sh --config tests/configs/qwen-mixed.json ``` The test runner injects its own prompt and setup script into the config, diff --git a/tests/configs/qwen-auth-mixed.json b/tests/configs/qwen-auth-mixed.json new file mode 100644 index 0000000..c306cd8 --- /dev/null +++ b/tests/configs/qwen-auth-mixed.json @@ -0,0 +1,20 @@ +{ + "prompt": "unused", + "driver": "qwen-cli", + "agents": [ + { + "count": 1, + "model": "qwen3.8-max", + "auth": "oauth" + }, + { + "count": 1, + "model": "qwen3.8-max", + "auth": "apikey" + }, + { + "count": 1, + "model": "qwen3.8-max" + } + ] +} diff --git a/tests/configs/qwen-mixed.json b/tests/configs/qwen-mixed.json new file mode 100644 index 0000000..93d5abc --- /dev/null +++ b/tests/configs/qwen-mixed.json @@ -0,0 +1,22 @@ +{ + "prompt": "unused", + "agents": [ + { + "count": 1, + "model": "claude-opus-4-6", + "driver": "claude-code", + "effort": "high" + }, + { + "count": 1, + "model": "qwen3.8-max", + "driver": "qwen-cli", + "effort": "high" + }, + { + "count": 1, + "model": "qwen3.8-max", + "driver": "qwen-cli" + } + ] +} diff --git a/tests/configs/qwen-oauth.json b/tests/configs/qwen-oauth.json new file mode 100644 index 0000000..bcd6021 --- /dev/null +++ b/tests/configs/qwen-oauth.json @@ -0,0 +1,17 @@ +{ + "prompt": "unused", + "driver": "qwen-cli", + "agents": [ + { + "count": 1, + "model": "qwen3.8-max", + "auth": "oauth", + "effort": "high" + }, + { + "count": 1, + "model": "qwen3.8-max", + "auth": "oauth" + } + ] +} diff --git a/tests/configs/qwen-only.json b/tests/configs/qwen-only.json new file mode 100644 index 0000000..5c1466d --- /dev/null +++ b/tests/configs/qwen-only.json @@ -0,0 +1,16 @@ +{ + "prompt": "unused", + "driver": "qwen-cli", + "agents": [ + { + "count": 1, + "model": "qwen3.8-max", + "effort": "high" + }, + { + "count": 1, + "model": "qwen3.8-max", + "effort": "low" + } + ] +} diff --git a/tests/test_config.sh b/tests/test_config.sh index 0e9793e..d4ae56d 100755 --- a/tests/test_config.sh +++ b/tests/test_config.sh @@ -1368,6 +1368,145 @@ _all_drivers=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | _kimi_count=$(echo "$_all_drivers" | grep -c "kimi-cli" || true) assert_eq "kimi-auth-mixed all kimi-cli" "3" "$_kimi_count" +# ============================================================ +echo "" +echo "=== 40. Qwen-only config ===" + +CFG="$TESTS_DIR/configs/qwen-only.json" + +assert_eq "qwen-only count" "2" "$(parse_num_agents "$CFG")" +assert_eq "qwen-only driver" "qwen-cli" "$(jq -r '.driver' "$CFG")" +assert_eq "qwen-only model[0]" "qwen3.8-max" \ + "$(jq -r '.agents[0].model' "$CFG")" +assert_eq "qwen-only effort[0]" "high" "$(jq -r '.agents[0].effort' "$CFG")" +assert_eq "qwen-only effort[1]" "low" "$(jq -r '.agents[1].effort' "$CFG")" + +# Agents inherit top-level driver. +QWEN_AGENTS=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | + (.driver // $dd // "claude-code")' "$CFG") +assert_eq "qwen-only agent inherits driver" "qwen-cli" \ + "$(echo "$QWEN_AGENTS" | sed -n '1p')" + +# ============================================================ +echo "" +echo "=== 41. Qwen OAuth auth config ===" + +CFG="$TESTS_DIR/configs/qwen-oauth.json" + +assert_eq "qwen-oauth count" "2" "$(parse_num_agents "$CFG")" +assert_eq "qwen-oauth driver" "qwen-cli" "$(jq -r '.driver' "$CFG")" +assert_eq "qwen-oauth model[0]" "qwen3.8-max" \ + "$(jq -r '.agents[0].model' "$CFG")" +assert_eq "qwen-oauth auth[0]" "oauth" "$(jq -r '.agents[0].auth' "$CFG")" +assert_eq "qwen-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 "qwen-oauth all auth=oauth" "2" "$_oauth_count" + +# ============================================================ +echo "" +echo "=== 42. Qwen home dir driver-level resolution ===" + +source "$TESTS_DIR/../lib/drivers/qwen-cli.sh" +_fake_qwen_home="$TMPDIR/fake-qwen-home" +mkdir -p "$_fake_qwen_home" + +# auth=apikey: env-forwarded key, no mount. +AUTH_OUT=$(QWEN_API_KEY="sk-sp-key" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "apikey" "") +assert_contains "qwen resolve apikey has key" \ + "DASHSCOPE_API_KEY=sk-sp-key" "$AUTH_OUT" +assert_contains "qwen resolve apikey label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" +_mount_count=$(echo "$AUTH_OUT" | grep -c -- "--mount" || true) +assert_eq "qwen resolve apikey no mount" "0" "$_mount_count" + +# auth=oauth: mounts the home dir, no key. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "oauth" "") +assert_contains "qwen resolve oauth mounts" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen resolve oauth readonly" "readonly" "$AUTH_OUT" +assert_contains "qwen resolve oauth label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "DASHSCOPE_API_KEY" || true) +assert_eq "qwen resolve oauth no key" "0" "$_key_count" + +# auth omitted, both available: auto-detect. +AUTH_OUT=$(QWEN_API_KEY="sk-auto-key" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "" "") +assert_contains "qwen resolve auto has key" \ + "DASHSCOPE_API_KEY=sk-auto-key" "$AUTH_OUT" +assert_contains "qwen resolve auto mounts" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen resolve auto label" "SWARM_AUTH_MODE=auto" "$AUTH_OUT" + +# auth omitted, only home dir: oauth label. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "" "") +assert_contains "qwen resolve oauth-only mount" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen resolve oauth-only label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" + +# QWEN_HOME pointing at a missing dir: no mount. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" \ + QWEN_HOME="/tmp/nonexistent-qwen-home" \ + agent_docker_auth "" "" "oauth" "" 2>/dev/null) +_mount_count=$(echo "$AUTH_OUT" | grep -c -- "--mount" || true) +assert_eq "qwen resolve missing dir no mount" "0" "$_mount_count" + +# ============================================================ +echo "" +echo "=== 43. Qwen-mixed config ===" + +CFG="$TESTS_DIR/configs/qwen-mixed.json" + +MIXED_COUNT=$(parse_num_agents "$CFG") +assert_eq "qwen-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") +QA1=$(echo "$MIXED_AGENTS" | sed -n '1p') +QA2=$(echo "$MIXED_AGENTS" | sed -n '2p') +QA3=$(echo "$MIXED_AGENTS" | sed -n '3p') +assert_eq "qwen-mixed agent1 driver" "claude-code" "$(echo "$QA1" | cut -d'|' -f2)" +assert_eq "qwen-mixed agent1 model" "claude-opus-4-6" "$(echo "$QA1" | cut -d'|' -f1)" +assert_eq "qwen-mixed agent2 driver" "qwen-cli" "$(echo "$QA2" | cut -d'|' -f2)" +assert_eq "qwen-mixed agent2 model" "qwen3.8-max" "$(echo "$QA2" | cut -d'|' -f1)" +assert_eq "qwen-mixed agent2 effort" "high" "$(jq -r '.agents[1].effort' "$CFG")" +assert_eq "qwen-mixed agent3 driver" "qwen-cli" "$(echo "$QA3" | cut -d'|' -f2)" +assert_eq "qwen-mixed agent3 model" "qwen3.8-max" "$(echo "$QA3" | cut -d'|' -f1)" +assert_eq "qwen-mixed agent3 effort" "null" "$(jq -r '.agents[2].effort // null' "$CFG")" + +# ============================================================ +echo "" +echo "=== 44. Qwen auth-mixed config ===" + +CFG="$TESTS_DIR/configs/qwen-auth-mixed.json" + +assert_eq "qwen-auth-mixed count" "3" "$(parse_num_agents "$CFG")" +assert_eq "qwen-auth-mixed groups" "3" "$(jq '.agents | length' "$CFG")" + +# Group 1: explicit oauth auth. +assert_eq "qwen-auth-mixed[0] auth" "oauth" "$(jq -r '.agents[0].auth' "$CFG")" +assert_eq "qwen-auth-mixed[0] model" "qwen3.8-max" \ + "$(jq -r '.agents[0].model' "$CFG")" + +# Group 2: explicit apikey auth. +assert_eq "qwen-auth-mixed[1] auth" "apikey" "$(jq -r '.agents[1].auth' "$CFG")" +assert_eq "qwen-auth-mixed[1] model" "qwen3.8-max" \ + "$(jq -r '.agents[1].model' "$CFG")" + +# Group 3: no auth field (auto-detect at runtime). +assert_eq "qwen-auth-mixed[2] auth" "null" "$(jq -r '.agents[2].auth // "null"' "$CFG")" +assert_eq "qwen-auth-mixed[2] model" "qwen3.8-max" \ + "$(jq -r '.agents[2].model' "$CFG")" + +# All inherit qwen-cli driver. +_all_drivers=$(jq -r '.driver as $dd | .agents[] | range(.count // 0) as $i | + (.driver // $dd // "claude-code")' "$CFG") +_qwen_count=$(echo "$_all_drivers" | grep -c "qwen-cli" || true) +assert_eq "qwen-auth-mixed all qwen-cli" "3" "$_qwen_count" + # ============================================================ echo "" echo "===============================" diff --git a/tests/test_dashboard.sh b/tests/test_dashboard.sh index 801dbd8..d3af7dd 100755 --- a/tests/test_dashboard.sh +++ b/tests/test_dashboard.sh @@ -64,6 +64,10 @@ 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 "qwen no effort" "qwen3.8-max" \ + "$(format_model qwen3.8-max "")" +assert_eq "qwen with effort" "qwen3.8-max (h)" \ + "$(format_model qwen3.8-max 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)" @@ -99,6 +103,7 @@ short_driver() { gemini-cli) printf 'gemini' ;; codex-cli) printf 'codex' ;; kimi-cli) printf 'kimi' ;; + qwen-cli) printf 'qwen' ;; *) printf '%s' "${1:-}" ;; esac } @@ -266,6 +271,7 @@ 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 "qwen-cli → qwen" "qwen" "$(short_driver qwen-cli)" assert_eq "fake passthrough" "fake" "$(short_driver fake)" assert_eq "unknown passthrough" "foo" "$(short_driver foo)" assert_eq "empty → empty" "" "$(short_driver "")" @@ -662,6 +668,31 @@ cat > "$TMPDIR/all_kimi.json" <<'EOF' EOF assert_eq "all kimi → 1 driver" "1" "$(detect_multi_drivers "$TMPDIR/all_kimi.json")" +cat > "$TMPDIR/mixed_qwen.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "qwen-cli", + "agents": [ + { "count": 1, "model": "qwen3.8-max" }, + { "count": 1, "model": "claude-opus-4-6", "driver": "claude-code" } + ] +} +EOF +assert_eq "qwen + claude override → 2 drivers" "2" \ + "$(detect_multi_drivers "$TMPDIR/mixed_qwen.json")" + +cat > "$TMPDIR/all_qwen.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "qwen-cli", + "agents": [ + { "count": 1, "model": "qwen3.8-max" }, + { "count": 1, "model": "qwen3.8-max" } + ] +} +EOF +assert_eq "all qwen → 1 driver" "1" "$(detect_multi_drivers "$TMPDIR/all_qwen.json")" + # ============================================================ echo "" echo "=== 11. Git info and default title ===" diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index 6e6e21e..c69ba13 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -61,6 +61,8 @@ 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 "qwen driver exists" "true" \ + "$([ -f "$DRIVERS_DIR/qwen-cli.sh" ] && echo true || echo false)" assert_eq "_common.sh exists" "true" \ "$([ -f "$DRIVERS_DIR/_common.sh" ] && echo true || echo false)" @@ -423,6 +425,12 @@ for fn in "${_required_fns[@]}"; do "$(type -t "$fn" &>/dev/null && echo true || echo false)" done +source "$DRIVERS_DIR/qwen-cli.sh" +for fn in "${_required_fns[@]}"; do + assert_eq "qwen has $fn" "true" \ + "$(type -t "$fn" &>/dev/null && echo true || echo false)" +done + # ============================================================ echo "" echo "=== 17. Gemini CLI driver — role interface ===" @@ -557,6 +565,9 @@ 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)" +source "$DRIVERS_DIR/qwen-cli.sh" +assert_eq "qwen default model" "qwen3.8-max" "$(agent_default_model)" + # ============================================================ echo "" echo "=== 22. agent_docker_auth — Claude Code driver ===" @@ -1327,7 +1338,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 kimi-cli; do +for _drv in claude-code codex-cli gemini-cli kimi-cli qwen-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" \ @@ -2011,6 +2022,523 @@ 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 "=== 50. Qwen driver — role interface ===" + +source "$DRIVERS_DIR/qwen-cli.sh" + +assert_eq "qwen name" "Qwen Code CLI" "$(agent_name)" +assert_eq "qwen cmd" "qwen" "$(agent_cmd)" +assert_eq "qwen default" "qwen3.8-max" "$(agent_default_model)" + +QWEN_JQ=$(agent_activity_jq) +assert_not_empty "qwen jq filter" "$QWEN_JQ" +assert_contains "qwen jq has tool_use" "tool_use" "$QWEN_JQ" +assert_contains "qwen jq has run_shell_command" "run_shell_command" "$QWEN_JQ" + +QWEN_INSTALL=$(agent_install_cmd) +assert_contains "qwen install uses npm package" \ + "@qwen-code/qwen-code" "$QWEN_INSTALL" +assert_contains "qwen install supports version" "QWEN_CLI_VERSION" "$QWEN_INSTALL" + +# The build-arg is threaded into the npm tag verbatim; an empty +# value expands to no tag ("latest"), so no conditional is needed +# on the Dockerfile side. +assert_contains "qwen install passes build-arg through" \ + '@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}' \ + "$QWEN_INSTALL" + +# The headless command shape depends on auth: with DASHSCOPE_API_KEY +# the driver synthesizes ~/.qwen/settings.json from the env and the +# -m target is the provider entry id (prefix stripped); without a +# key the mounted host config owns the aliases and the swarmfile +# alias is passed verbatim. +( + unset QWEN_BASE_URL QWEN_REASONING_EFFORT QWEN_CONTEXT_WINDOW + HOME="$TMPDIR/qwen-shape-home" DASHSCOPE_API_KEY="sk-sp-test" \ + bash -c ' + source "$1" + _run_reaped() { shift; printf "%s\n" "$*"; } + agent_run "dashscope/qwen3.8-max" "p" "$2/shape.log" + ' _ "$DRIVERS_DIR/qwen-cli.sh" "$TMPDIR" +) > "$TMPDIR/qwen-shape-key.txt" +assert_eq "qwen apikey: -m alias not passed" "0" \ + "$(grep -c -- '-m dashscope/qwen3.8-max' \ + "$TMPDIR/qwen-shape-key.txt" || true)" +assert_contains "qwen apikey: -m target" \ + "-m qwen3.8-max" "$(cat "$TMPDIR/qwen-shape-key.txt")" +assert_contains "qwen apikey: yolo passed" \ + "--yolo" "$(cat "$TMPDIR/qwen-shape-key.txt")" +assert_contains "qwen apikey: stream-json" \ + "stream-json" "$(cat "$TMPDIR/qwen-shape-key.txt")" +assert_eq "qwen apikey: settings synthesized" "qwen3.8-max" \ + "$(jq -r '.model.name' "$TMPDIR/qwen-shape-home/.qwen/settings.json")" +assert_eq "qwen apikey: auth type openai" "openai" \ + "$(jq -r '.security.auth.selectedType' \ + "$TMPDIR/qwen-shape-home/.qwen/settings.json")" +assert_eq "qwen apikey: envKey" "DASHSCOPE_API_KEY" \ + "$(jq -r '.modelProviders.openai[0].envKey' \ + "$TMPDIR/qwen-shape-home/.qwen/settings.json")" + +( + unset DASHSCOPE_API_KEY + HOME="$TMPDIR/qwen-shape-home2" \ + bash -c ' + source "$1" + _run_reaped() { shift; printf "%s\n" "$*"; } + agent_run "dashscope/qwen3.8-max" "p" "$2/shape.log" + ' _ "$DRIVERS_DIR/qwen-cli.sh" "$TMPDIR" +) > "$TMPDIR/qwen-shape-oauth.txt" +assert_contains "qwen oauth: -m alias verbatim" \ + "-m dashscope/qwen3.8-max" "$(cat "$TMPDIR/qwen-shape-oauth.txt")" +assert_eq "qwen oauth: no settings written" "false" \ + "$([ -e "$TMPDIR/qwen-shape-home2/.qwen/settings.json" ] \ + && echo true || echo false)" + +# The append-system-prompt file is inlined as text (qwen's flag +# takes the prompt, not a path). +( + unset DASHSCOPE_API_KEY + echo "EXTRA RULES" > "$TMPDIR/qwen-append.md" + HOME="$TMPDIR/qwen-shape-home3" \ + bash -c ' + source "$1" + _run_reaped() { shift; printf "%s\n" "$*"; } + agent_run "qwen3.8-max" "p" "$2/shape.log" "$2/qwen-append.md" + ' _ "$DRIVERS_DIR/qwen-cli.sh" "$TMPDIR" +) > "$TMPDIR/qwen-shape-append.txt" +assert_contains "qwen append inlined" \ + "--append-system-prompt EXTRA RULES" \ + "$(cat "$TMPDIR/qwen-shape-append.txt")" + +# Optional env knobs land in the synthesized provider entry. +HOME="$TMPDIR/qwen-shape-home4" \ + QWEN_BASE_URL="https://example.com/v1" \ + QWEN_REASONING_EFFORT="high" \ + QWEN_CONTEXT_WINDOW="1000000" \ + DASHSCOPE_API_KEY="sk-sp-test" \ + bash -c ' + source "$1" + _run_reaped() { shift; :; } + agent_run "qwen3.8-max" "p" "$2/shape.log" + ' _ "$DRIVERS_DIR/qwen-cli.sh" "$TMPDIR" +assert_eq "qwen base_url in settings" "https://example.com/v1" \ + "$(jq -r '.modelProviders.openai[0].baseUrl' \ + "$TMPDIR/qwen-shape-home4/.qwen/settings.json")" +assert_eq "qwen context window in settings" "1000000" \ + "$(jq -r '.modelProviders.openai[0].generationConfig.contextWindowSize' \ + "$TMPDIR/qwen-shape-home4/.qwen/settings.json")" +assert_eq "qwen effort in settings" "high" \ + "$(jq -r '.model.reasoningEffort' \ + "$TMPDIR/qwen-shape-home4/.qwen/settings.json")" + +# Unset knobs stay out of the file entirely. +assert_eq "qwen no base_url key when unset" "false" \ + "$(jq 'has("baseUrl")' < <(jq '.modelProviders.openai[0]' \ + "$TMPDIR/qwen-shape-home/.qwen/settings.json"))" +assert_eq "qwen no generationConfig when unset" "false" \ + "$(jq 'has("generationConfig")' < <(jq '.modelProviders.openai[0]' \ + "$TMPDIR/qwen-shape-home/.qwen/settings.json"))" +assert_eq "qwen no reasoningEffort when unset" "false" \ + "$(jq 'has("reasoningEffort")' < <(jq '.model' \ + "$TMPDIR/qwen-shape-home/.qwen/settings.json"))" + +# ============================================================ +echo "" +echo "=== 51. Qwen driver — agent_settings ===" + +QWORK="$TMPDIR/qwen-workspace" +mkdir -p "$QWORK/.git/info" + +# 51a. Staged oauth mount is copied to a writable home dir. +QHOME1="$TMPDIR/qwen-home1" +mkdir -p "$QHOME1/.qwen-host" +echo "token" > "$QHOME1/.qwen-host/oauth_creds.json" +HOME="$QHOME1" agent_settings "$QWORK" +assert_eq "qwen staged dir copied" "token" \ + "$(cat "$QHOME1/.qwen/oauth_creds.json")" + +# 51b. An existing home dir is never overwritten by the staged copy. +QHOME2="$TMPDIR/qwen-home2" +mkdir -p "$QHOME2/.qwen" "$QHOME2/.qwen-host" +echo "local" > "$QHOME2/.qwen/oauth_creds.json" +echo "host" > "$QHOME2/.qwen-host/oauth_creds.json" +HOME="$QHOME2" agent_settings "$QWORK" +assert_eq "qwen existing home wins" "local" \ + "$(cat "$QHOME2/.qwen/oauth_creds.json")" + +# 51c. No staged mount: no home dir created (apikey auth needs no +# files on disk at settings time). +QHOME3="$TMPDIR/qwen-home3" +mkdir -p "$QHOME3" +HOME="$QHOME3" agent_settings "$QWORK" +assert_eq "qwen no home without staged mount" "false" \ + "$([ -e "$QHOME3/.qwen" ] && echo true || echo false)" + +# 51d. AGENTS.md bridge: .claude/CLAUDE.md copied when no AGENTS.md. +QWORK_B="$TMPDIR/qwen-bridge" +mkdir -p "$QWORK_B/.claude" "$QWORK_B/.git/info" +echo "# Project rules" > "$QWORK_B/.claude/CLAUDE.md" +HOME="$QHOME3" agent_settings "$QWORK_B" +assert_eq "qwen AGENTS.md bridged" "# Project rules" \ + "$(cat "$QWORK_B/AGENTS.md")" +assert_contains "qwen AGENTS.md in git exclude" "AGENTS.md" \ + "$(cat "$QWORK_B/.git/info/exclude")" + +# 51e. Existing AGENTS.md not overwritten. +QWORK_C="$TMPDIR/qwen-bridge-existing" +mkdir -p "$QWORK_C/.claude" "$QWORK_C/.git/info" +echo "# Qwen rules" > "$QWORK_C/AGENTS.md" +echo "# Claude rules" > "$QWORK_C/.claude/CLAUDE.md" +HOME="$QHOME3" agent_settings "$QWORK_C" +assert_eq "qwen existing AGENTS.md preserved" "# Qwen rules" \ + "$(cat "$QWORK_C/AGENTS.md")" + +# 51f. A native QWEN.md also suppresses the bridge. +QWORK_D="$TMPDIR/qwen-bridge-qwenmd" +mkdir -p "$QWORK_D/.claude" "$QWORK_D/.git/info" +echo "# Native qwen rules" > "$QWORK_D/QWEN.md" +echo "# Claude rules" > "$QWORK_D/.claude/CLAUDE.md" +HOME="$QHOME3" agent_settings "$QWORK_D" +assert_eq "qwen QWEN.md suppresses bridge" "false" \ + "$([ -f "$QWORK_D/AGENTS.md" ] && echo true || echo false)" + +# 51g. Skills bridge: .claude/skills/ symlinked to .qwen/skills/. +QWORK_E="$TMPDIR/qwen-skills" +mkdir -p "$QWORK_E/.claude/skills/triage" "$QWORK_E/.git/info" +echo "---" > "$QWORK_E/.claude/skills/triage/SKILL.md" +HOME="$QHOME3" agent_settings "$QWORK_E" +assert_eq "qwen skills symlink created" "true" \ + "$([ -L "$QWORK_E/.qwen/skills" ] && echo true || echo false)" +assert_eq "qwen skill resolves" "---" \ + "$(cat "$QWORK_E/.qwen/skills/triage/SKILL.md")" +assert_contains "qwen .qwen/skills in git exclude" ".qwen/skills" \ + "$(cat "$QWORK_E/.git/info/exclude")" + +# 51h. Existing .qwen/skills/ not overwritten. +QWORK_F="$TMPDIR/qwen-skills-existing" +mkdir -p "$QWORK_F/.qwen/skills/custom" "$QWORK_F/.claude/skills/other" \ + "$QWORK_F/.git/info" +echo "custom" > "$QWORK_F/.qwen/skills/custom/SKILL.md" +HOME="$QHOME3" agent_settings "$QWORK_F" +assert_eq "qwen existing .qwen/skills preserved" "custom" \ + "$(cat "$QWORK_F/.qwen/skills/custom/SKILL.md")" +assert_eq "qwen .qwen/skills not a symlink" "false" \ + "$([ -L "$QWORK_F/.qwen/skills" ] && echo true || echo false)" + +# 51i. AGENTS.md bridge: root CLAUDE.md used as fallback. +QWORK_G="$TMPDIR/qwen-bridge-root" +mkdir -p "$QWORK_G/.git/info" +echo "# Root rules" > "$QWORK_G/CLAUDE.md" +HOME="$QHOME3" agent_settings "$QWORK_G" +assert_eq "qwen AGENTS.md from root CLAUDE.md" "# Root rules" \ + "$(cat "$QWORK_G/AGENTS.md")" + +# ============================================================ +echo "" +echo "=== 52. Qwen driver — agent_extract_stats ===" + +cat > "$TMPDIR/qwen-session.jsonl" <<'EOF' +{"type":"system","subtype":"session_start","session_id":"s1"} +{"type":"assistant","message":{"content":[{"type":"tool_use","name":"run_shell_command","input":{"command":"ls"}}]}} +{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} +{"type":"result","subtype":"success","is_error":false,"duration_ms":1234,"result":"Done.","usage":{"input_tokens":100,"output_tokens":50,"cache_read_input_tokens":10}} +EOF + +QSTATS=$(agent_extract_stats "$TMPDIR/qwen-session.jsonl") +IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS" + +assert_eq "qwen cost is 0 (no cost in stream-json)" "0" "$q_cost" +assert_eq "qwen tok_in" "100" "$q_in" +assert_eq "qwen tok_out" "50" "$q_out" +assert_eq "qwen cache_rd" "10" "$q_cache_rd" +assert_eq "qwen duration" "1234" "$q_dur" +assert_eq "qwen turns falls back to assistant count" "2" "$q_turns" + +# num_turns on the result object wins over the assistant count. +cat > "$TMPDIR/qwen-turns.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} +{"type":"result","subtype":"success","is_error":false,"num_turns":7,"usage":{"input_tokens":1,"output_tokens":1}} +EOF +QSTATS=$(agent_extract_stats "$TMPDIR/qwen-turns.jsonl") +IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS" +assert_eq "qwen num_turns from result" "7" "$q_turns" + +# Empty log: all zeroes. +: > "$TMPDIR/qwen-empty.jsonl" +QSTATS_EMPTY=$(agent_extract_stats "$TMPDIR/qwen-empty.jsonl") +IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS_EMPTY" +assert_eq "qwen empty cost" "0" "$q_cost" +assert_eq "qwen empty turns" "0" "$q_turns" + +# ============================================================ +echo "" +echo "=== 53. Qwen driver — agent_detect_fatal ===" + +# Fatal: terminal result object with is_error:true. +cat > "$TMPDIR/qwen-iserror.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"partial"}]}} +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"401 Invalid API-key"} +EOF +QFATAL=$(agent_detect_fatal "$TMPDIR/qwen-iserror.jsonl" 1) +assert_contains "qwen is_error detected" "401 Invalid API-key" "$QFATAL" + +# Fatal: stderr error with no assistant output. +: > "$TMPDIR/qwen-stderr.jsonl" +cat > "$TMPDIR/qwen-stderr.jsonl.err" <<'EOF' +Error: Unauthorized - invalid API key +EOF +QFATAL=$(agent_detect_fatal "$TMPDIR/qwen-stderr.jsonl" 1) +assert_not_empty "qwen stderr error detected" "$QFATAL" +assert_contains "qwen fatal mentions unauthorized" "Unauthorized" "$QFATAL" + +# Not fatal: successful result with is_error:false. +cat > "$TMPDIR/qwen-ok.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Done."} +EOF +QOK=$(agent_detect_fatal "$TMPDIR/qwen-ok.jsonl" 0) +assert_eq "qwen ok not flagged" "" "$QOK" + +# Not fatal: assistant output present despite stderr noise. +cat > "$TMPDIR/qwen-noise.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} +EOF +cat > "$TMPDIR/qwen-noise.jsonl.err" <<'EOF' +Warning: transient error retried successfully +EOF +QNOISE=$(agent_detect_fatal "$TMPDIR/qwen-noise.jsonl" 0) +assert_eq "qwen stderr noise not flagged" "" "$QNOISE" + +# ============================================================ +echo "" +echo "=== 54. Qwen driver — agent_is_retriable ===" + +# Rate limit wording from an OpenAI-compatible streaming error. +cat > "$TMPDIR/qwen-429.jsonl" <<'EOF' +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"OpenAI API Streaming Error: 429 Too many requests"} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/qwen-429.jsonl" 1) +assert_not_empty "qwen 429 is retriable" "$RETRY_OUT" + +# DashScope's throttling error code (image and text endpoints). +cat > "$TMPDIR/qwen-throttle.jsonl" <<'EOF' +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"HTTP 429 Throttling.RateQuota"} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/qwen-throttle.jsonl" 1) +assert_not_empty "qwen throttling is retriable" "$RETRY_OUT" + +: > "$TMPDIR/qwen-rate-stderr.jsonl" +cat > "$TMPDIR/qwen-rate-stderr.jsonl.err" <<'EOF' +Error: rate limit exceeded, please retry later +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/qwen-rate-stderr.jsonl" 1) +assert_not_empty "qwen rate limit in stderr is retriable" "$RETRY_OUT" + +cat > "$TMPDIR/qwen-503.jsonl" <<'EOF' +{"type":"result","subtype":"error_during_execution","is_error":true,"result":"503 service unavailable"} +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/qwen-503.jsonl" 1) +assert_not_empty "qwen 503 is retriable" "$RETRY_OUT" + +: > "$TMPDIR/qwen-auth-err.jsonl" +cat > "$TMPDIR/qwen-auth-err.jsonl.err" <<'EOF' +Error: Invalid API key +EOF +RETRY_OUT=$(agent_is_retriable "$TMPDIR/qwen-auth-err.jsonl" 1) +assert_eq "qwen auth error not retriable" "" "$RETRY_OUT" + +# ============================================================ +echo "" +echo "=== 55. Qwen driver — agent_docker_env ===" + +QWEN_ENV=$(agent_docker_env "high") +assert_contains "qwen docker_env effort flag" \ + "QWEN_REASONING_EFFORT=high" "$QWEN_ENV" + +QWEN_ENV=$(agent_docker_env "") +assert_eq "qwen docker_env empty effort" "" "$QWEN_ENV" + +# ============================================================ +echo "" +echo "=== 56. Qwen driver — agent_docker_auth ===" + +# API key from per-agent config (explicit apikey mode). +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ + agent_docker_auth "sk-sp-key" "" "apikey" "") +assert_contains "qwen per-agent key" "DASHSCOPE_API_KEY=sk-sp-key" "$AUTH_OUT" +assert_contains "qwen per-agent label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" +assert_contains "qwen yolo warning suppressed" \ + "QWEN_CODE_SUPPRESS_YOLO_WARNING=1" "$AUTH_OUT" + +# API key from environment (explicit apikey mode). +AUTH_OUT=$(QWEN_API_KEY="sk-env-key" DASHSCOPE_API_KEY="" \ + QWEN_HOME="/nonexistent" agent_docker_auth "" "" "apikey" "") +assert_contains "qwen env key" "DASHSCOPE_API_KEY=sk-env-key" "$AUTH_OUT" +assert_contains "qwen env key label" "SWARM_AUTH_MODE=key" "$AUTH_OUT" + +# DASHSCOPE_API_KEY is accepted as a host-side fallback. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="sk-dash-key" \ + QWEN_HOME="/nonexistent" agent_docker_auth "" "" "apikey" "") +assert_contains "qwen dashscope env key" \ + "DASHSCOPE_API_KEY=sk-dash-key" "$AUTH_OUT" + +# Per-agent overrides env. +AUTH_OUT=$(QWEN_API_KEY="sk-env" DASHSCOPE_API_KEY="" \ + QWEN_HOME="/nonexistent" agent_docker_auth "sk-agent" "" "apikey" "") +assert_contains "qwen per-agent overrides env" \ + "DASHSCOPE_API_KEY=sk-agent" "$AUTH_OUT" + +# base_url feeds the synthesized provider entry's endpoint. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ + agent_docker_auth "sk-sp" "" "apikey" "https://api.example.com/v1") +assert_contains "qwen base_url forwarded" \ + "QWEN_BASE_URL=https://api.example.com/v1" "$AUTH_OUT" + +# No credentials at all. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ + agent_docker_auth "" "" "" "") +assert_contains "qwen no creds has auth mode" "SWARM_AUTH_MODE=" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "DASHSCOPE_API_KEY" || true) +assert_eq "qwen no creds no key flag" "0" "$_key_count" + +# OAuth mode: mounts the host home dir read-only. +_fake_qwen_home="$TMPDIR/fake-qwen-home" +mkdir -p "$_fake_qwen_home" +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="$_fake_qwen_home" \ + agent_docker_auth "" "" "oauth" "") +assert_contains "qwen oauth mounts home dir" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen oauth mount flag" "--mount" "$AUTH_OUT" +assert_contains "qwen oauth mount readonly" "readonly" "$AUTH_OUT" +assert_contains "qwen oauth label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" +_key_count=$(echo "$AUTH_OUT" | grep -c "DASHSCOPE_API_KEY" || true) +assert_eq "qwen oauth no api key" "0" "$_key_count" + +# OAuth mode but home dir missing: warns, no mount. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ + agent_docker_auth "" "" "oauth" "" 2>/dev/null) +_mount_count=$(echo "$AUTH_OUT" | grep -c "\-\-mount" || true) +assert_eq "qwen oauth missing no mount" "0" "$_mount_count" + +# Auto-detect: API key + home dir both present. +AUTH_OUT=$(QWEN_API_KEY="sk-both" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "" "") +assert_contains "qwen auto has api key" "DASHSCOPE_API_KEY=sk-both" "$AUTH_OUT" +assert_contains "qwen auto mounts home dir" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen auto label" "SWARM_AUTH_MODE=auto" "$AUTH_OUT" + +# Auto-detect: only home dir, no key. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" \ + QWEN_HOME="$_fake_qwen_home" agent_docker_auth "" "" "" "") +assert_contains "qwen auto oauth-only mount" "$_fake_qwen_home" "$AUTH_OUT" +assert_contains "qwen auto oauth-only label" "SWARM_AUTH_MODE=oauth" "$AUTH_OUT" + +# ============================================================ +echo "" +echo "=== 57. Qwen driver — activity jq filter via file boundary ===" + +source "$DRIVERS_DIR/qwen-cli.sh" +agent_activity_jq > "$TMPDIR/qwen.jq" + +QWEN_SHELL='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"run_shell_command","input":{"command":"npm test"}}]}}' +Q_SHELL_OUT=$(echo "$QWEN_SHELL" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq shell" "Shell:" "$Q_SHELL_OUT" +assert_contains "qwen jq shell command" "npm test" "$Q_SHELL_OUT" + +QWEN_READ='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"read_file","input":{"file_path":"src/main.ts"}}]}}' +Q_READ_OUT=$(echo "$QWEN_READ" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq read" "Read " "$Q_READ_OUT" +assert_contains "qwen jq read path" "src/main.ts" "$Q_READ_OUT" + +QWEN_EDIT='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"edit","input":{"file_path":"/workspace/src/utils.ts"}}]}}' +Q_EDIT_OUT=$(echo "$QWEN_EDIT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq edit" "Edit " "$Q_EDIT_OUT" +assert_contains "qwen jq edit path" "/workspace/src/utils.ts" "$Q_EDIT_OUT" + +QWEN_GREP='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"grep_search","input":{"pattern":"TODO"}}]}}' +Q_GREP_OUT=$(echo "$QWEN_GREP" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq grep" "Grep " "$Q_GREP_OUT" +assert_contains "qwen jq grep pattern" "TODO" "$Q_GREP_OUT" + +QWEN_SEARCH='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"web_search","input":{"query":"qwen stream-json"}}]}}' +Q_SEARCH_OUT=$(echo "$QWEN_SEARCH" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq web_search" "Search:" "$Q_SEARCH_OUT" + +QWEN_AGENT='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"agent","input":{"description":"explore repo"}}]}}' +Q_AGENT_OUT=$(echo "$QWEN_AGENT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq agent" "Agent:" "$Q_AGENT_OUT" +assert_contains "qwen jq agent description" "explore repo" "$Q_AGENT_OUT" + +QWEN_THINK='{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking the config"}]}}' +Q_THINK_OUT=$(echo "$QWEN_THINK" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq thinking" "Think:" "$Q_THINK_OUT" + +QWEN_UNKNOWN='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"todo_write","input":{}}]}}' +Q_UNK_OUT=$(echo "$QWEN_UNKNOWN" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_contains "qwen jq unknown tool" "todo_write" "$Q_UNK_OUT" + +# Result, system, and text-only assistant messages are silently +# skipped -- only tool calls and thinking carry the signal. +QWEN_RESULT='{"type":"result","subtype":"success","is_error":false}' +Q_RESULT_OUT=$(echo "$QWEN_RESULT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "qwen jq result silent" "" "$Q_RESULT_OUT" + +QWEN_SYSTEM='{"type":"system","subtype":"session_start","session_id":"s1"}' +Q_SYSTEM_OUT=$(echo "$QWEN_SYSTEM" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "qwen jq system silent" "" "$Q_SYSTEM_OUT" + +QWEN_TEXT='{"type":"assistant","message":{"content":[{"type":"text","text":"All done."}]}}' +Q_TEXT_OUT=$(echo "$QWEN_TEXT" | \ + AGENT_ID=8 SWARM_JQ_FILTER_FILE="$TMPDIR/qwen.jq" \ + bash "$FILTER_DIR/activity-filter.sh" 2>/dev/null || true) +assert_eq "qwen jq text-only assistant silent" "" "$Q_TEXT_OUT" + +# ============================================================ +echo "" +echo "=== 58. Qwen driver in config parsing ===" + +cat > "$TMPDIR/qwen_cfg.json" <<'EOF' +{ + "prompt": "p.md", + "driver": "qwen-cli", + "agents": [ + { "count": 1, "model": "qwen3.8-max" }, + { "count": 1, "model": "gpt-5.4", "driver": "codex-cli" } + ] +} +EOF + +TOP_DRIVER=$(jq -r '.driver // "claude-code"' "$TMPDIR/qwen_cfg.json") +assert_eq "qwen top-level driver" "qwen-cli" "$TOP_DRIVER" + +AGENTS=$(jq -r '.driver as $dd | .agents[] | + (.driver // $dd // "claude-code")' "$TMPDIR/qwen_cfg.json") +LINE1=$(echo "$AGENTS" | sed -n '1p') +LINE2=$(echo "$AGENTS" | sed -n '2p') +assert_eq "qwen agent1 inherits top driver" "qwen-cli" "$LINE1" +assert_eq "qwen agent2 per-agent driver" "codex-cli" "$LINE2" + # ============================================================ echo "" echo "===============================" diff --git a/tests/test_harness.sh b/tests/test_harness.sh index 0326b19..9625383 100755 --- a/tests/test_harness.sh +++ b/tests/test_harness.sh @@ -680,6 +680,50 @@ assert_eq "backoff capped at 1800" "1800" "$(simulate_backoff 10)" "$(probe_retriable 'model_not_found: kimi-unknown is not available')" ) +# --- 14d. qwen-cli agent_is_retriable classification --- +# +# Same two-class contract as kimi: 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/qwen-cli.sh" + + probe_retriable() { + local log="$TMPDIR/qwen-retriable-$$-${RANDOM}.log" + printf '%s\n' "$1" > "$log" + agent_is_retriable "$log" 1 + rm -f "$log" + } + + # Rate-limit class. + assert_eq "qwen retriable: 429 response" "rate_limited" \ + "$(probe_retriable 'HTTP 429 Too Many Requests')" + assert_eq "qwen retriable: dashscope throttling" "rate_limited" \ + "$(probe_retriable 'Throttling.RateQuota')" + assert_eq "qwen retriable: quota exhaustion" "rate_limited" \ + "$(probe_retriable 'quota exceeded for the current plan')" + + # Transient class. + assert_eq "qwen retriable: 502 bad gateway" "transient" \ + "$(probe_retriable 'HTTP 502 Bad Gateway')" + assert_eq "qwen retriable: 503 service unavailable" "transient" \ + "$(probe_retriable 'HTTP 503 Service Unavailable')" + assert_eq "qwen retriable: connection reset" "transient" \ + "$(probe_retriable 'connection reset by peer')" + assert_eq "qwen retriable: request timeout" "transient" \ + "$(probe_retriable 'request timed out after 60s')" + assert_eq "qwen retriable: overloaded" "transient" \ + "$(probe_retriable 'The server is overloaded, please retry later')" + + # Genuinely fatal errors must NOT be reclassified as retriable. + assert_eq "qwen non-retriable: auth error" "" \ + "$(probe_retriable 'Unauthorized: invalid API key')" + assert_eq "qwen non-retriable: model not found" "" \ + "$(probe_retriable 'model_not_found: qwen-unknown is not available')" +) + # ============================================================ echo "" echo "=== 12. SSH signing config ===" diff --git a/tests/test_launch.sh b/tests/test_launch.sh index 652387e..820dafc 100755 --- a/tests/test_launch.sh +++ b/tests/test_launch.sh @@ -1034,6 +1034,27 @@ JSON assert_eq "kimi version absent" "" \ "$(jq -r '.kimi_cli_version // empty' "$TMPDIR/kimi_no_version.json")" +cat > "$TMPDIR/qwen_pinned.json" <<'JSON' +{ + "prompt": "unused", + "qwen_cli_version": "0.21.5", + "agents": [{"count": 1, "driver": "qwen-cli", "model": "qwen3.8-max"}] +} +JSON + +assert_eq "qwen version present" "0.21.5" \ + "$(jq -r '.qwen_cli_version // empty' "$TMPDIR/qwen_pinned.json")" + +cat > "$TMPDIR/qwen_no_version.json" <<'JSON' +{ + "prompt": "unused", + "agents": [{"count": 1, "driver": "qwen-cli", "model": "qwen3.8-max"}] +} +JSON + +assert_eq "qwen version absent" "" \ + "$(jq -r '.qwen_cli_version // empty' "$TMPDIR/qwen_no_version.json")" + # ============================================================ echo "" echo "=== 30. Top-level tag inheritance ===" @@ -1682,6 +1703,14 @@ EOF assert_eq "kimi-only top-level driver" "kimi-cli" \ "$(compute_swarm_agents "$TMPDIR/csa_kimi.json")" +cat > "$TMPDIR/csa_qwen.json" <<'EOF' +{ "prompt": "p.md", + "driver": "qwen-cli", + "agents": [{ "count": 1, "model": "qwen3.8-max" }] } +EOF +assert_eq "qwen-only top-level driver" "qwen-cli" \ + "$(compute_swarm_agents "$TMPDIR/csa_qwen.json")" + cat > "$TMPDIR/csa_mixed.json" <<'EOF' { "prompt": "p.md", "agents": [ @@ -1819,6 +1848,9 @@ assert_eq "build_image forwards CODEX_CLI_VERSION build-arg" "1" \ assert_eq "build_image forwards KIMI_CLI_VERSION build-arg" "1" \ "$(printf '%s\n' "$_bi_body" \ | grep -cE -- '--build-arg "KIMI_CLI_VERSION=' || true)" +assert_eq "build_image forwards QWEN_CLI_VERSION build-arg" "1" \ + "$(printf '%s\n' "$_bi_body" \ + | grep -cE -- '--build-arg "QWEN_CLI_VERSION=' || true)" # ============================================================ echo "" From 22b150c4363d14d40f0f99033f7a85b1f5d1e7eb Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:49:49 +0000 Subject: [PATCH 14/19] Forward QWEN_CONTEXT_WINDOW into agent containers --- USAGE.md | 7 ++++--- lib/drivers/qwen-cli.sh | 10 +++++++++- tests/test_drivers.sh | 7 +++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/USAGE.md b/USAGE.md index 7c98bec..a27801c 100644 --- a/USAGE.md +++ b/USAGE.md @@ -674,9 +674,10 @@ built-in default endpoint is China (cn-beijing). International accounts must set `base_url` (as above for a Token Plan key, or `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` for a standard pay-as-you-go key) or every request fails with -`401 Invalid API-key`. Optional overrides: `QWEN_CONTEXT_WINDOW` -(adds `generationConfig.contextWindowSize` to the synthesized -provider entry). +`401 Invalid API-key`. Optional host-side override: +`QWEN_CONTEXT_WINDOW` (forwarded into the container; adds +`generationConfig.contextWindowSize` to the synthesized provider +entry — e.g. `1000000` for qwen3.8-max's 1M window). For OAuth auth, configure `~/.qwen` on the host (run `qwen` and use `/auth`, or `bl config agent --agent qwen-code` for Alibaba Cloud diff --git a/lib/drivers/qwen-cli.sh b/lib/drivers/qwen-cli.sh index 6779c79..1692ef3 100644 --- a/lib/drivers/qwen-cli.sh +++ b/lib/drivers/qwen-cli.sh @@ -289,7 +289,8 @@ agent_docker_env() { # Resolve auth credentials and emit Docker flags. # Args: -# Reads host env: QWEN_API_KEY, DASHSCOPE_API_KEY, QWEN_HOME +# Reads host env: QWEN_API_KEY, DASHSCOPE_API_KEY, QWEN_HOME, +# QWEN_CONTEXT_WINDOW # # Auth modes: # oauth — Mount the host qwen dir (~/.qwen) after `qwen` /auth @@ -342,6 +343,13 @@ agent_docker_auth() { printf -- '-e\nQWEN_BASE_URL=%s\n' "$base_url" fi + # Optional context-window override, picked up by the settings + # writer for the synthesized provider entry (e.g. 1000000 for + # qwen3.8-max's 1M window). + if [ -n "${QWEN_CONTEXT_WINDOW:-}" ]; then + printf -- '-e\nQWEN_CONTEXT_WINDOW=%s\n' "$QWEN_CONTEXT_WINDOW" + fi + # Headless runs always pass --yolo; silence the one-line # no-sandbox startup warning (the container is the sandbox). printf -- '-e\nQWEN_CODE_SUPPRESS_YOLO_WARNING=1\n' diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index c69ba13..74c88e6 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -2396,6 +2396,13 @@ AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ assert_contains "qwen base_url forwarded" \ "QWEN_BASE_URL=https://api.example.com/v1" "$AUTH_OUT" +# QWEN_CONTEXT_WINDOW is forwarded for the settings writer. +AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ + QWEN_CONTEXT_WINDOW="1000000" \ + agent_docker_auth "sk-sp" "" "apikey" "") +assert_contains "qwen context window forwarded" \ + "QWEN_CONTEXT_WINDOW=1000000" "$AUTH_OUT" + # No credentials at all. AUTH_OUT=$(QWEN_API_KEY="" DASHSCOPE_API_KEY="" QWEN_HOME="/nonexistent" \ agent_docker_auth "" "" "" "") From 0702ba6753321d2d64e016ad927d534cf714edb6 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:40:41 +0000 Subject: [PATCH 15/19] Install qwen-cli from the official standalone archive --- Dockerfile | 16 +++++++++++----- USAGE.md | 7 ++++--- lib/drivers/qwen-cli.sh | 9 ++++++--- tests/test_drivers.sh | 21 ++++++++++++++------- 4 files changed, 35 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2863b04..1373649 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,9 @@ RUN if echo ",$SWARM_AGENTS," | grep -q ",claude-code,"; then \ fi ENV PATH="/home/agent/.local/bin:${PATH}" -# --- Node.js (shared by Gemini CLI, Codex CLI and Qwen Code CLI) --- +# --- Node.js (shared by Gemini CLI and Codex CLI) --- USER root -RUN if echo ",$SWARM_AGENTS," | grep -qE ",(gemini-cli|codex-cli|qwen-cli),"; then \ +RUN if echo ",$SWARM_AGENTS," | grep -qE ",(gemini-cli|codex-cli),"; then \ curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/*; \ @@ -66,10 +66,16 @@ RUN if echo ",$SWARM_AGENTS," | grep -q ",kimi-cli,"; then \ # --- Qwen Code CLI --- ARG QWEN_CLI_VERSION= +# Official standalone archive (no Node required). Installs to +# /usr/local/bin so the agent user finds qwen on PATH without the +# script editing anyone's shell rc; "latest" when unpinned. RUN if echo ",$SWARM_AGENTS," | grep -q ",qwen-cli,"; then \ - npm install -g "@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}" \ - && mkdir -p /home/agent/.qwen \ - && chown agent:agent /home/agent/.qwen; \ + curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh -o /tmp/qwen-install.sh \ + && QWEN_INSTALL_ROOT=/usr/local QWEN_NO_MODIFY_PATH=1 \ + QWEN_INSTALL_METHOD=standalone \ + QWEN_INSTALL_VERSION="${QWEN_CLI_VERSION:-latest}" \ + bash /tmp/qwen-install.sh \ + && rm /tmp/qwen-install.sh; \ fi USER agent diff --git a/USAGE.md b/USAGE.md index a27801c..390fd10 100644 --- a/USAGE.md +++ b/USAGE.md @@ -819,9 +819,10 @@ swarmfile: { "qwen_cli_version": "0.21.5" } ``` -The value is forwarded to `npm install -g @qwen-code/qwen-code@` -inside the image build. Leave the field unset (or empty) to -keep the default "latest published release" behavior. +The value is passed to the official standalone installer +(`install-qwen-standalone.sh`, no Node.js required) inside the +image build. Leave the field unset (or empty) to install the +latest release. ### Writing a new driver diff --git a/lib/drivers/qwen-cli.sh b/lib/drivers/qwen-cli.sh index 1692ef3..720205f 100644 --- a/lib/drivers/qwen-cli.sh +++ b/lib/drivers/qwen-cli.sh @@ -360,8 +360,11 @@ agent_docker_auth() { # QWEN_CLI_VERSION is a Docker build-arg; empty = latest. agent_install_cmd() { cat <<'INSTALL' -RUN npm install -g "@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}" \ - && mkdir -p /home/agent/.qwen \ - && chown agent:agent /home/agent/.qwen +RUN curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh -o /tmp/qwen-install.sh \ + && QWEN_INSTALL_ROOT=/usr/local QWEN_NO_MODIFY_PATH=1 \ + QWEN_INSTALL_METHOD=standalone \ + QWEN_INSTALL_VERSION="${QWEN_CLI_VERSION:-latest}" \ + bash /tmp/qwen-install.sh \ + && rm /tmp/qwen-install.sh INSTALL } diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index 74c88e6..c1bcde5 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -2038,16 +2038,23 @@ assert_contains "qwen jq has tool_use" "tool_use" "$QWEN_JQ" assert_contains "qwen jq has run_shell_command" "run_shell_command" "$QWEN_JQ" QWEN_INSTALL=$(agent_install_cmd) -assert_contains "qwen install uses npm package" \ - "@qwen-code/qwen-code" "$QWEN_INSTALL" +assert_contains "qwen install uses official script" \ + "qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh" \ + "$QWEN_INSTALL" +assert_contains "qwen install targets /usr/local" \ + "QWEN_INSTALL_ROOT=/usr/local" "$QWEN_INSTALL" +assert_contains "qwen install leaves shell rc alone" \ + "QWEN_NO_MODIFY_PATH=1" "$QWEN_INSTALL" +assert_contains "qwen install skips the npm fallback" \ + "QWEN_INSTALL_METHOD=standalone" "$QWEN_INSTALL" assert_contains "qwen install supports version" "QWEN_CLI_VERSION" "$QWEN_INSTALL" -# The build-arg is threaded into the npm tag verbatim; an empty -# value expands to no tag ("latest"), so no conditional is needed -# on the Dockerfile side. +# The build-arg is threaded into QWEN_INSTALL_VERSION verbatim, with +# an explicit "latest" fallback so unpinned builds track the newest +# release (the script also defaults to a hardcoded version, which +# would silently freeze unpinned images). assert_contains "qwen install passes build-arg through" \ - '@qwen-code/qwen-code${QWEN_CLI_VERSION:+@$QWEN_CLI_VERSION}' \ - "$QWEN_INSTALL" + 'QWEN_INSTALL_VERSION="${QWEN_CLI_VERSION:-latest}"' "$QWEN_INSTALL" # The headless command shape depends on auth: with DASHSCOPE_API_KEY # the driver synthesizes ~/.qwen/settings.json from the env and the From 1ed734fee4025810cfb9a77d730e7f787c2754f9 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:52:21 +0000 Subject: [PATCH 16/19] Detect qwen quota exhaustion reported as successful results --- lib/drivers/qwen-cli.sh | 23 +++++++++++++++++++++-- tests/test_drivers.sh | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/lib/drivers/qwen-cli.sh b/lib/drivers/qwen-cli.sh index 720205f..ab8cdea 100644 --- a/lib/drivers/qwen-cli.sh +++ b/lib/drivers/qwen-cli.sh @@ -224,8 +224,10 @@ JQ # Detect fatal errors in a Qwen session log. # A failed run ends with a result object carrying is_error:true; -# startup failures (bad key, unreachable endpoint) may surface only -# on stderr with no assistant messages on stdout. +# provider quota/rate failures are reported as a *successful* result +# whose text carries the error; startup failures (bad key, +# unreachable endpoint) may surface only on stderr with no +# assistant messages on stdout. agent_detect_fatal() { local logfile="$1" @@ -241,6 +243,23 @@ agent_detect_fatal() { 2>/dev/null return fi + + # qwen-code reports provider quota/rate failures as a + # *successful* result (is_error:false, exit 0) with the error + # as the result text (observed on token-plan exhaustion: + # "Quota exhausted: ... (cause: insufficient_quota: 429 ...)"). + # Surface the strong provider-error phrases so the harness + # enters its backoff loop instead of counting the session as + # a successful idle one. Kept deliberately narrow: a finding + # report may legitimately mention "rate limit" in prose. + local result_text + result_text=$(echo "$result_line" \ + | jq -r '.result // empty' 2>/dev/null || true) + if printf '%s' "$result_text" \ + | grep -qi 'quota exhausted\|insufficient_quota'; then + printf '%s\n' "$result_text" | head -1 + return + fi fi if [ -f "${logfile}.err" ]; then diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index c1bcde5..e0c87de 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -2308,6 +2308,26 @@ EOF QOK=$(agent_detect_fatal "$TMPDIR/qwen-ok.jsonl" 0) assert_eq "qwen ok not flagged" "" "$QOK" +# Fatal: provider quota exhaustion reported as a *successful* +# result (observed on the token plan: is_error:false, exit 0). +cat > "$TMPDIR/qwen-quota.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"Quota exhausted: Your token-plan 5-hour quota has been exhausted. (cause: insufficient_quota: 429)"}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Quota exhausted: Your token-plan 5-hour quota has been exhausted. (cause: insufficient_quota: 429)"} +EOF +QQUOTA=$(agent_detect_fatal "$TMPDIR/qwen-quota.jsonl" 0) +assert_contains "qwen quota-as-success detected" "Quota exhausted" "$QQUOTA" +assert_eq "qwen quota-as-success is retriable" "rate_limited" \ + "$(agent_is_retriable "$TMPDIR/qwen-quota.jsonl" 0)" + +# Not fatal: a successful report that merely mentions quotas in +# prose stays below the detection threshold. +cat > "$TMPDIR/qwen-prose.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"The endpoint enforces rate limits."}]}} +{"type":"result","subtype":"success","is_error":false,"result":"Analysis complete: the endpoint enforces rate limits per plan."} +EOF +QPROSE=$(agent_detect_fatal "$TMPDIR/qwen-prose.jsonl" 0) +assert_eq "qwen prose not flagged" "" "$QPROSE" + # Not fatal: assistant output present despite stderr noise. cat > "$TMPDIR/qwen-noise.jsonl" <<'EOF' {"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} From 53403af38551a483f73b1258b156e6d8a3e4b655 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:50:43 +0000 Subject: [PATCH 17/19] Read kimi token usage from the session wire.jsonl --- CHANGELOG.md | 8 ++++-- lib/drivers/kimi-cli.sh | 53 +++++++++++++++++++++++++++++++++++++--- tests/test_drivers.sh | 54 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9664588..c4047d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,12 @@ `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. + stream-json carries no usage summary; token usage is summed + from the session `wire.jsonl` the CLI persists (one + `usage.record` per LLM step), attributed by run-start mark so a + staged host home or an earlier retry is never counted. Cache + creation folds into `tok_in` (billed as a cache miss upstream); + 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 diff --git a/lib/drivers/kimi-cli.sh b/lib/drivers/kimi-cli.sh index 68a41a2..c8550ba 100644 --- a/lib/drivers/kimi-cli.sh +++ b/lib/drivers/kimi-cli.sh @@ -45,6 +45,12 @@ agent_run() { # --auto, or --plan. Headless mode already runs under the auto # permission policy, so there is nothing to opt into. # + # Remember when this run started: agent_extract_stats sums token + # usage from the session wire.jsonl the CLI persists, and the mark + # keeps sessions copied in with the staged home (agent_settings) + # or left by earlier retries from being attributed to this run. + SWARM_KIMI_RUN_MARK=$(date +%s) + # # _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 @@ -124,8 +130,17 @@ agent_settings() { # 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. +# usage or cost summary (verified against kimi-code 0.31.1). Token +# usage is summed instead from the session wire the CLI persists at +# ~/.kimi-code/sessions///agents/main/wire.jsonl: one +# usage.record per LLM step, with disjoint inputOther / +# inputCacheRead / inputCacheCreation / output buckets. Cache +# creation is billed as a cache miss upstream, so it folds into +# tok_in; cost comes from the swarmfile pricing map. Only wires +# touched since SWARM_KIMI_RUN_MARK (set by agent_run) count, so a +# staged host home or an earlier retry is never attributed. Without +# a mark (standalone invocation) the newest wire is used. Sub-agent +# wires (agents//wire.jsonl) are not summed. # turns counts assistant messages, the only per-step signal in the # stream. agent_extract_stats() { @@ -134,8 +149,40 @@ agent_extract_stats() { turns=$(grep -c '"role"[[:space:]]*:[[:space:]]*"assistant"' \ "$logfile" 2>/dev/null || true) turns="${turns:-0}" + + local tok_in=0 tok_out=0 cache_rd=0 cache_cr=0 + local _home="${KIMI_CODE_HOME:-${HOME}/.kimi-code}" + local _usage_jsonl="" + if [ -n "${SWARM_KIMI_RUN_MARK:-}" ]; then + _usage_jsonl=$(find "$_home/sessions" \ + -path '*/agents/main/wire.jsonl' \ + -newermt "@$(( SWARM_KIMI_RUN_MARK - 2 ))" \ + -exec cat {} + 2>/dev/null || true) + else + local _wire + _wire=$(ls -t "$_home"/sessions/*/*/agents/main/wire.jsonl \ + 2>/dev/null | head -1 || true) + if [ -n "$_wire" ]; then + _usage_jsonl=$(cat "$_wire" 2>/dev/null || true) + fi + fi + if [ -n "$_usage_jsonl" ]; then + # fromjson? skips the truncated tail line a killed run leaves. + local _sums + _sums=$(printf '%s\n' "$_usage_jsonl" | jq -Rr ' + fromjson? // empty + | select(.type == "usage.record") | .usage + | [ (.inputOther // 0), (.output // 0), + (.inputCacheRead // 0), (.inputCacheCreation // 0) ] + | @tsv' 2>/dev/null | awk ' + { i += $1; o += $2; r += $3; c += $4 } + END { printf "%d %d %d %d", i, o, r, c }') + read -r tok_in tok_out cache_rd cache_cr <<< "${_sums:-0 0 0 0}" + tok_in=$(( ${tok_in:-0} + ${cache_cr:-0} )) + fi printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s" \ - "0" "0" "0" "0" "0" "0" "0" "$turns" + "0" "${tok_in:-0}" "${tok_out:-0}" "${cache_rd:-0}" \ + "${cache_cr:-0}" "0" "0" "$turns" } # Return the jq program for parsing activity from Kimi stream-json. diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index e0c87de..8a34478 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -1775,7 +1775,10 @@ cat > "$TMPDIR/kimi-session.jsonl" <<'EOF' {"role":"assistant","content":"Done."} EOF -KSTATS=$(agent_extract_stats "$TMPDIR/kimi-session.jsonl") +# Isolate from any real ~/.kimi-code on the dev machine: with no +# session wire in sight every token field stays 0. +KSTATS=$(KIMI_CODE_HOME="$TMPDIR/kimi-nohome" \ + 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" @@ -1785,11 +1788,58 @@ 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") +KSTATS_EMPTY=$(KIMI_CODE_HOME="$TMPDIR/kimi-nohome" \ + 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" +# Token usage is summed from the session wire.jsonl the CLI persists +# (one usage.record per LLM step). Cache creation folds into tok_in +# (billed as a cache miss upstream). +KWIRE_HOME="$TMPDIR/kimi-wirehome" +mkdir -p "$KWIRE_HOME/sessions/wd_test_abc/session_s1/agents/main" +cat > "$KWIRE_HOME/sessions/wd_test_abc/session_s1/agents/main/wire.jsonl" <<'EOF' +{"type":"metadata","protocol_version":"1.4","created_at":1785851223263} +{"type":"usage.record","model":"kimi-code/k3","usage":{"inputOther":2705,"output":19,"inputCacheRead":18944,"inputCacheCreation":0}} +{"type":"usage.record","model":"kimi-code/k3","usage":{"inputOther":728,"output":172,"inputCacheRead":259328,"inputCacheCreation":512}} +EOF +# A killed run leaves a truncated tail line; the parser must skip it. +printf '{"type":"usage.record","usage":{"inputOth' \ + >> "$KWIRE_HOME/sessions/wd_test_abc/session_s1/agents/main/wire.jsonl" + +KSTATS=$(KIMI_CODE_HOME="$KWIRE_HOME" \ + 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 wire tok_in (cache creation = miss)" "3945" "$k_in" +assert_eq "kimi wire tok_out" "191" "$k_out" +assert_eq "kimi wire cache_rd" "278272" "$k_cache_rd" +assert_eq "kimi wire cache_cr" "512" "$k_cache_cr" +assert_eq "kimi wire turns" "2" "$k_turns" + +# Wires older than the run mark are not attributed: the staged host +# home and earlier retries share the same sessions tree. +mkdir -p "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main" +cat > "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main/wire.jsonl" <<'EOF' +{"type":"usage.record","model":"kimi-code/k3","usage":{"inputOther":9000,"output":900,"inputCacheRead":90000,"inputCacheCreation":90}} +EOF +touch -d '1 hour ago' \ + "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main/wire.jsonl" + +KSTATS=$(KIMI_CODE_HOME="$KWIRE_HOME" SWARM_KIMI_RUN_MARK="$(date +%s)" \ + 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 mark: old wire ignored" "3945" "$k_in" + +# A mark newer than every wire attributes nothing. +KSTATS=$(KIMI_CODE_HOME="$KWIRE_HOME" \ + SWARM_KIMI_RUN_MARK=$(( $(date +%s) + 3600 )) \ + 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 future mark: tok_in 0" "0" "$k_in" +assert_eq "kimi future mark: turns still counted" "2" "$k_turns" + # ============================================================ echo "" echo "=== 44. Kimi driver — agent_detect_fatal ===" From 968077cdd64d8bdddd0eeadbf9128133a28c8253 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:34:17 +0000 Subject: [PATCH 18/19] Fix kimi wire mark portability on macOS --- lib/drivers/kimi-cli.sh | 22 ++++++++++++++++++---- tests/test_drivers.sh | 3 ++- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/drivers/kimi-cli.sh b/lib/drivers/kimi-cli.sh index c8550ba..ea520cc 100644 --- a/lib/drivers/kimi-cli.sh +++ b/lib/drivers/kimi-cli.sh @@ -154,10 +154,24 @@ agent_extract_stats() { local _home="${KIMI_CODE_HOME:-${HOME}/.kimi-code}" local _usage_jsonl="" if [ -n "${SWARM_KIMI_RUN_MARK:-}" ]; then - _usage_jsonl=$(find "$_home/sessions" \ - -path '*/agents/main/wire.jsonl' \ - -newermt "@$(( SWARM_KIMI_RUN_MARK - 2 ))" \ - -exec cat {} + 2>/dev/null || true) + # Portable mtime filter: BSD find has no -newermt, so compare + # stat mtimes instead (stat -c %Y is GNU, stat -f %m is BSD). + # The 2-second slack covers same-second writes. + local _w _mtime + _usage_jsonl=$( + for _w in "$_home"/sessions/*/*/agents/main/wire.jsonl; do + [ -f "$_w" ] || continue + _mtime=$(stat -c %Y "$_w" 2>/dev/null \ + || stat -f %m "$_w" 2>/dev/null || echo 0) + if [ "${_mtime:-0}" -gt \ + $(( SWARM_KIMI_RUN_MARK - 2 )) ]; then + cat "$_w" + # Guarantee a line boundary between wires: a + # killed run leaves a truncated tail line. + echo + fi + done + ) else local _wire _wire=$(ls -t "$_home"/sessions/*/*/agents/main/wire.jsonl \ diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index 8a34478..5c95d6e 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -1824,7 +1824,8 @@ mkdir -p "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main" cat > "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main/wire.jsonl" <<'EOF' {"type":"usage.record","model":"kimi-code/k3","usage":{"inputOther":9000,"output":900,"inputCacheRead":90000,"inputCacheCreation":90}} EOF -touch -d '1 hour ago' \ +# BSD touch has no -d; a fixed past timestamp is portable. +touch -t 202001010000.00 \ "$KWIRE_HOME/sessions/wd_test_abc/session_s0/agents/main/wire.jsonl" KSTATS=$(KIMI_CODE_HOME="$KWIRE_HOME" SWARM_KIMI_RUN_MARK="$(date +%s)" \ From 24b3ae8ad55c59ebd39e50e0693d478dc42f0ee1 Mon Sep 17 00:00:00 2001 From: Simone Orsi <241460653+simone-stacks@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:37:58 +0000 Subject: [PATCH 19/19] Subtract folded cache reads from qwen input tokens --- CHANGELOG.md | 4 +++- lib/drivers/qwen-cli.sh | 25 ++++++++++++++++++++++++- tests/test_drivers.sh | 27 ++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4047d5..e36d70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ from stream-json tool calls, fatal/retriable error detection, and reasoning effort support via `model.reasoningEffort`. stream-json carries usage but no cost, so cost comes from the - swarmfile `pricing` map. + swarmfile `pricing` map. The CLI folds cache reads into + `input_tokens`; the driver subtracts them so `tok_in` matches + Claude's disjoint buckets and cache reads are not billed twice. - **Qwen auth modes.** Qwen agents authenticate via `"auth": "apikey"` (host `QWEN_API_KEY`/`DASHSCOPE_API_KEY` forwarded as `DASHSCOPE_API_KEY`; the driver synthesizes diff --git a/lib/drivers/qwen-cli.sh b/lib/drivers/qwen-cli.sh index ab8cdea..6cb9a0a 100644 --- a/lib/drivers/qwen-cli.sh +++ b/lib/drivers/qwen-cli.sh @@ -171,7 +171,30 @@ agent_extract_stats() { "$logfile" 2>/dev/null || true) stats="${stats%$'\t'*} ${turns:-0}" fi - printf '%s' "$stats" + + # qwen folds cache reads into usage.input_tokens: its total_tokens + # is input + output, so a session whose cache_read_input_tokens + # nearly equals input_tokens spent almost nothing on fresh input + # (verified against real session logs; qwen reports no + # cache-creation bucket). The pricing map expects Claude's + # disjoint buckets, where input_tokens excludes cache reads, so + # subtract them here -- in the qwen driver only, the shared parser + # stays generic -- or cache reads are billed twice. + local cost tok_in tok_out cache_rd tail + cost="${stats%%$'\t'*}" + tail="${stats#*$'\t'}" + tok_in="${tail%%$'\t'*}" + tail="${tail#*$'\t'}" + tok_out="${tail%%$'\t'*}" + tail="${tail#*$'\t'}" + cache_rd="${tail%%$'\t'*}" + tail="${tail#*$'\t'}" + tok_in=$(( tok_in - cache_rd )) + if [ "$tok_in" -lt 0 ]; then + tok_in=0 + fi + printf '%s\t%s\t%s\t%s\t%s' \ + "$cost" "$tok_in" "$tok_out" "$cache_rd" "$tail" } # Return the jq program for parsing activity from Qwen stream-json. diff --git a/tests/test_drivers.sh b/tests/test_drivers.sh index 5c95d6e..3d643c5 100755 --- a/tests/test_drivers.sh +++ b/tests/test_drivers.sh @@ -2307,13 +2307,38 @@ EOF QSTATS=$(agent_extract_stats "$TMPDIR/qwen-session.jsonl") IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS" +# qwen folds cache reads into input_tokens; the driver subtracts +# them so tok_in matches Claude's disjoint buckets. assert_eq "qwen cost is 0 (no cost in stream-json)" "0" "$q_cost" -assert_eq "qwen tok_in" "100" "$q_in" +assert_eq "qwen tok_in excludes cache reads" "90" "$q_in" assert_eq "qwen tok_out" "50" "$q_out" assert_eq "qwen cache_rd" "10" "$q_cache_rd" assert_eq "qwen duration" "1234" "$q_dur" assert_eq "qwen turns falls back to assistant count" "2" "$q_turns" +# Real-session shape: cache_read_input_tokens nearly equals +# input_tokens because total_tokens is input + output. Numbers from +# an actual qwen3.8-max run. +cat > "$TMPDIR/qwen-cache.jsonl" <<'EOF' +{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}} +{"type":"result","subtype":"success","is_error":false,"duration_ms":1481344,"duration_api_ms":1477652,"num_turns":81,"result":"Done.","usage":{"input_tokens":11080283,"output_tokens":54285,"cache_read_input_tokens":10893116,"total_tokens":11134568}} +EOF +QSTATS=$(agent_extract_stats "$TMPDIR/qwen-cache.jsonl") +IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS" +assert_eq "qwen cached session tok_in" "187167" "$q_in" +assert_eq "qwen cached session tok_out" "54285" "$q_out" +assert_eq "qwen cached session cache_rd" "10893116" "$q_cache_rd" +assert_eq "qwen cached session turns" "81" "$q_turns" + +# No cache reads: tok_in passes through unchanged. +cat > "$TMPDIR/qwen-nocache.jsonl" <<'EOF' +{"type":"result","subtype":"success","is_error":false,"num_turns":3,"result":"Done.","usage":{"input_tokens":64000,"output_tokens":1200,"total_tokens":65200}} +EOF +QSTATS=$(agent_extract_stats "$TMPDIR/qwen-nocache.jsonl") +IFS=$'\t' read -r q_cost q_in q_out q_cache_rd q_cache_cr q_dur q_api_ms q_turns <<< "$QSTATS" +assert_eq "qwen no-cache tok_in unchanged" "64000" "$q_in" +assert_eq "qwen no-cache cache_rd zero" "0" "$q_cache_rd" + # num_turns on the result object wins over the assistant count. cat > "$TMPDIR/qwen-turns.jsonl" <<'EOF' {"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}}