diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fa915..4bb96f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## Unreleased + +### Changed + +- The built-in `kimi` agent now drives Kimi Code CLI (`@moonshot-ai/kimi-code`), the successor to + the legacy Python `kimi-cli` that 0.6.0 targeted. Both install a `kimi` executable, but Kimi Code + rejects `--quiet` and `--work-dir`. Reviews run in prompt mode with a shipped read-only profile + (`lib/agents/kimi-reviewer.md`: no Edit or Write tools, read-only sub-agents only), the report is + read from `--output-format stream-json`, and replies resume the exact session id Kimi prints. + The entry stays opt-in. Users of the legacy CLI can keep the old command with a `jury.config.json` + override; see [docs/configuration.md](docs/configuration.md#authentication-versions-and-conversations). + +### Added + +- `report: "kimi-json"` reads the assistant text out of Kimi Code's JSON lines, ignoring tool + results and metadata, and rejects non-JSON or empty output; the console shows the decoded words + while the agent runs. `{{packageDir}}` in a command names the installed package directory, for + files that ship with it. + ## 0.6.0 — 2026-09-16 - Validate new agents through fresh installed CLI tests; fix Qwen worktree and Git access, Copilot Git permissions, final structured result handling, and conversation isolation. All six new agents are opt-in. diff --git a/README.md b/README.md index 33730ed..5d7012e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ It uses illustrative data and does not start agents. | | GitHub Copilot CLI | `copilot` | Opt-in | | | Cursor Agent CLI | `cursor` | Opt-in | | | Sourcegraph Amp | `amp` | Opt-in | -| | Moonshot Kimi CLI | `kimi` | Opt-in | +| | Moonshot Kimi Code CLI | `kimi` | Opt-in | **Opt-in** agents are fully supported as reviewers and judges, but stay out of the default pool so a fresh install does not require every CLI to be present. Select one by name — no configuration @@ -178,7 +178,7 @@ or select installed reviewers explicitly. | `opencode` | OpenCode | `npm i -g opencode-ai` | opt-in | | `cursor` | Cursor Agent CLI | `curl https://cursor.com/install -fsS \| bash` | opt-in | | `amp` | Sourcegraph Amp | `npm i -g @sourcegraph/amp` | opt-in | -| `kimi` | Moonshot Kimi CLI | `uv tool install --python 3.13 kimi-cli` | opt-in | +| `kimi` | Moonshot Kimi Code CLI | `npm i -g @moonshot-ai/kimi-code` | opt-in | **Opt-in** agents are fully supported — usable with `--reviewer`, `--jury`, `--judge` and `jury agents judge` — but stay out of the default pool, so a fresh install does not require every diff --git a/docs/cli-test-coverage.md b/docs/cli-test-coverage.md index ad10715..2de7c66 100644 --- a/docs/cli-test-coverage.md +++ b/docs/cli-test-coverage.md @@ -18,7 +18,7 @@ they do not validate a provider's current authentication or CLI compatibility. | `finding reproduce` | Evidence persisted and visible in subsequent listing; invalid inputs do not mutate events | `cli-commands` | | `finding resolve` | All four verdicts; acceptance requires reproduction and test evidence; persisted output | `cli-commands` | | `finding settled` | Empty output and regenerated file agree with resolved findings | `cli-commands` | -| `reply` | Selected reviewers receive their own findings; reply events/output; empty reply refused | `reviewer-selection`, `new-agents`, `cli-commands` | +| `reply` | Selected reviewers receive their own findings; reply events/output; empty reply refused | `reviewer-selection`, `new-agents`, `kimi`, `cli-commands` | | `runs` | Empty listing and saved run slug, title, rounds, finding counts | `cli-commands` | | `agents` | Executable availability, missing executable failure, effective judge role | `cli-commands`, agent integration tests | | `agents judge ` | Every built-in set, queried in a different directory, and shown as main in listing | `cli-commands` | diff --git a/docs/configuration.md b/docs/configuration.md index b5def52..3b4ee43 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,8 +40,12 @@ and remain visible in the saved reports. `my-reviewer` is a placeholder executable, not an included product. Use arguments supported by your CLI. Supported prompt delivery is `argv` or `file` (`{{promptFile}}`); stdin is closed. `cwd: "worktree"` starts in the checkout; with `cwd: "flag"`, pass `{{worktree}}` in the command. -`report: "tail"` extracts a final response from a verbose transcript; otherwise the whole output is read. +`report: "tail"` extracts a final response from a verbose transcript; `report: "kimi-json"` reads the +assistant messages out of Kimi Code's `--output-format stream-json` lines; otherwise the whole output is read. `expectSeconds` informs the timeout (at least 600 seconds, normally three times that estimate). +Command placeholders are `{{promptText}}`, `{{promptFile}}`, `{{worktree}}`, `{{sessionId}}` (for +resumable agents), and `{{packageDir}}`, the directory Code Jury is installed in, for files that ship +with it such as `lib/agents/kimi-reviewer.md`. The historical YAML example is design documentation; the CLI loads JSON. A judge may specify separate `judgeArgv`. A custom `argv` overrides the built-in judge invocation @@ -90,7 +94,7 @@ review could not start until every supported CLI was installed. | OpenCode (opt-in) | `opencode` | Plan agent with explicit edit/task/shell restrictions | Build agent with `--auto` | | Cursor Agent (opt-in) | `cursor-agent` | ⚠️ Print mode; all tools including write and bash | `--force` | | Amp (opt-in) | `amp` | ⚠️ Execute mode; no read-only flag | Same command unless overridden | -| Kimi (opt-in) | `kimi` | ⚠️ Print mode auto-approves tool calls | Same command unless overridden | +| Kimi Code (opt-in) | `kimi` | Prompt mode with the shipped `lib/agents/kimi-reviewer.md` profile: Edit and Write tools removed, only the read-only `explore` sub-agent; shell available | Prompt mode with all tools | | Custom | Your `argv[0]` | Your `argv` | Your `judgeArgv`, or `argv` | ⚠️ marks agents whose CLI offers **no read-only invocation**. They still review, but only the @@ -111,7 +115,11 @@ your environment. Run records include prompts and outputs, so redact them before ### Authentication, versions, and conversations -- Kimi: verified CLI 1.50.0; run `kimi login`. Quiet mode prints the final message and auto-approves tools, even if a conversation initially starts in plan mode. Both roles therefore require a trusted worktree; replies start fresh. +- Kimi Code: verified CLI 0.39.1 and 0.42.0 (`npm install -g @moonshot-ai/kimi-code`); run `kimi login`, or put an API key in `~/.kimi-code/config.toml`, whose `default_model` is the model used. Prompt mode (`kimi -p`) rejects `--plan`, `--yolo` and `--auto` and approves every tool call itself, so the reviewer boundary is the shipped agent profile: it wraps Kimi's default prompt, removes the Edit and Write tools, and allows only the read-only `explore` sub-agent (the default `coder` sub-agent can write). The shell stays available, so the profile is not a sandbox. The report is read from `--output-format stream-json`, and replies resume the exact session id Kimi prints at the end of the stream; the profile flag is omitted on resume because Kimi refuses it next to `--session`, and the session keeps the agent it was created with. Kimi keeps its own session history per working directory under `~/.kimi-code`. The legacy Python `kimi-cli` installs an executable of the same name but takes different flags (`kimi --version` prints 1.x for it and 0.x for Kimi Code); to keep using it, override the entry in `jury.config.json`: + + ```json + { "agents": [{ "name": "kimi", "argv": ["kimi", "--quiet", "--work-dir", "{{worktree}}", "--prompt", "{{promptText}}"], "report": "whole", "resume": { "supported": false, "reason": "quiet mode prints no session id" } }] } + ``` - Cursor: verified `cursor-agent` 2025.10.28-0a91dc2; run `cursor-agent login`. The generic executable `agent` may belong to another product, so Jury uses `cursor-agent`. Final JSON must explicitly report success. Replies start fresh. - Copilot: verified CLI 0.0.392 flags; authenticate with interactive `/login`. A reviewer can inspect files and Git but cannot use write tools; the judge allows tools. Local permission configuration remains trusted. Replies start fresh. - Qwen: targets CLI 0.23.4 (`npm install -g @qwen-code/qwen-code`); run `qwen` and complete `/auth` before headless use. It runs in the process worktree, not merely an added access directory. Reviews use assigned UUIDs, and replies resume only that UUID; no implicit latest session. Final JSON must explicitly report success. diff --git a/lib/agent-schema.js b/lib/agent-schema.js index 9550f78..9c5fa9b 100644 --- a/lib/agent-schema.js +++ b/lib/agent-schema.js @@ -14,7 +14,7 @@ export const PROMPT_DELIVERY = ["argv", "file", "stdin"]; export const CWD_MODES = ["worktree", "flag"]; /** How much of stdout is the agent's report. */ -export const REPORT_MODES = ["whole", "tail", "opencode-json", "result-json"]; +export const REPORT_MODES = ["whole", "tail", "opencode-json", "result-json", "kimi-json"]; /** * How strongly the reviewer invocation is prevented from writing. diff --git a/lib/agents.js b/lib/agents.js index 8e521d0..6d48796 100644 --- a/lib/agents.js +++ b/lib/agents.js @@ -4,6 +4,17 @@ import { randomUUID } from "node:crypto"; import { mkdtemp, writeFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Where Code Jury itself is installed, for files that ship with it. + * + * An agent that takes a profile or policy file on its command line needs an + * absolute path to one — the process starts in the worktree, so a relative + * path would resolve into the code under review. kimi's read-only reviewer + * profile in lib/agents/ is reached this way, as {{packageDir}}. + */ +export const PACKAGE_DIR = fileURLToPath(new URL("..", import.meta.url)).replace(/[\\/]+$/, ""); function subst(argv, vars) { return argv.map((a) => a.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? "")); @@ -44,7 +55,7 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL const assigned = agent.newSession && agent.argv.some(arg => arg.includes("{{sessionId}}")) ? randomUUID() : null; const vars = { worktree, promptFile: promptFile ?? "", promptText: prompt, - sessionId: assigned ?? "", + sessionId: assigned ?? "", packageDir: PACKAGE_DIR, }; const [cmd, ...args] = subst(agent.argv, vars); const cwd = agent.cwd === "worktree" ? worktree : process.cwd(); @@ -81,10 +92,13 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL // reviewer that takes twenty minutes is otherwise a black box for all // twenty: nothing reaches disk until close, so "still thinking" and // "wedged" look identical to anyone watching. - child.stdout.on("data", (d) => { stdout += d; onChunk?.(String(d)); }); + // A kimi-json agent's live output is JSON lines; the console wants the + // words inside them, so those are decoded per complete line as they land. + const speak = onChunk && agent.report === "kimi-json" ? lineDecoder(onChunk) : onChunk; + child.stdout.on("data", (d) => { stdout += d; speak?.(String(d)); }); child.stderr.on("data", (d) => (stderr += d)); child.on("error", (err) => { clearTimeout(timer); resolve({ code: -1, stdout, stderr: stderr + String(err) }); }); - child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); + child.on("close", (code) => { clearTimeout(timer); speak?.flush?.(); resolve({ code, stdout, stderr }); }); }); if (tmp) await rm(tmp, { recursive: true, force: true }); @@ -150,6 +164,7 @@ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onL */ export function extractReport(stdout, mode = "whole") { const text = stdout.replace(/\r/g, ""); + if (mode === "kimi-json") return kimiText(text); if (mode === "result-json") { let data; try { data = JSON.parse(text); } catch { throw new Error("Invalid agent JSON output"); } @@ -194,6 +209,96 @@ export function extractReport(stdout, mode = "whole") { return lines.slice(-80).join("\n").trim(); } +/** + * The assistant's words out of Kimi Code's `--output-format stream-json`. + * + * One JSON object per line: assistant messages, the tool calls they made, the + * tool results, and metadata — the version banner, retry notices, and the + * session hint at the end. Only the assistant's text is the report: a tool + * result that happens to print the prompt's "NO NEW FINDINGS" line must not + * end the loop. A line that is not JSON means the agent was not run in + * stream-json mode, and a stream with no assistant text is a turn that never + * answered; both are errors rather than a report to be read. + */ +export function kimiText(text) { + const said = []; + for (const line of text.split("\n").filter((l) => l.trim())) { + const o = parseLine(line); + if (!o) throw new Error("Invalid Kimi Code JSON output"); + if (o.role !== "assistant") continue; + const t = contentText(o.content).trim(); + if (t) said.push(t); + } + if (!said.length) throw new Error("Kimi Code returned no assistant text"); + return said.join("\n\n"); +} + +function parseLine(line) { + if (!line.trim().startsWith("{")) return null; + try { + const o = JSON.parse(line); + return o && typeof o === "object" && !Array.isArray(o) ? o : null; + } catch { + return null; + } +} + +/** Message content as text, whether it is a string or a list of typed parts. */ +function contentText(content) { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter((p) => p && typeof p === "object" && p.type === "text" && typeof p.text === "string") + .map((p) => p.text) + .join("\n"); +} + +/** + * Live output from a kimi-json agent, as words rather than JSON. + * + * Streaming the raw lines to the console shows escaped newlines and tool + * payloads in place of a reviewer mid-sentence. Each complete line is decoded + * as it lands: the assistant's text as it is, a tool call as one short line so + * the console can tell "reading the diff" from "wedged", and tool results and + * metadata not at all. The raw stream is still kept whole for the artifact and + * the final parse. + */ +function lineDecoder(onChunk) { + let pending = ""; + const emit = (line) => { + const text = spokenLine(line); + if (text) onChunk(text); + }; + const decode = (chunk) => { + pending += chunk; + const lines = pending.split("\n"); + pending = lines.pop(); + for (const line of lines) emit(line); + }; + decode.flush = () => { + if (pending.trim()) emit(pending); + pending = ""; + }; + return decode; +} + +function spokenLine(line) { + const o = parseLine(line); + // Not JSON: a warning, or a misconfigured agent. Shown as it is; the final + // parse is what decides whether that was acceptable. + if (!o) return line.trim() ? `${line.replace(/\r$/, "")}\n` : ""; + if (o.role !== "assistant") return ""; + const parts = []; + const text = contentText(o.content).trim(); + if (text) parts.push(text); + for (const call of Array.isArray(o.tool_calls) ? o.tool_calls : []) { + const name = call?.function?.name ?? call?.name ?? "tool"; + const args = String(call?.function?.arguments ?? call?.arguments ?? "").replace(/\s+/g, " "); + parts.push(`» ${name} ${args.length > 160 ? `${args.slice(0, 160)}…` : args}`.trimEnd()); + } + return parts.length ? `${parts.join("\n")}\n` : ""; +} + // Agents write markdown even when asked for plain text, so strip the decoration // before matching rather than spelling every variant out in the pattern. // Headings and list numbering count: "### FINDING:" and "1. FINDING:" are both diff --git a/lib/agents/kimi-reviewer.md b/lib/agents/kimi-reviewer.md new file mode 100644 index 0000000..f96cd92 --- /dev/null +++ b/lib/agents/kimi-reviewer.md @@ -0,0 +1,17 @@ +--- +name: jury-reviewer +description: Independent code reviewer for Code Jury. Reads a change and reports findings; never edits files. +disallowedTools: + - Edit + - Write +subagents: + - explore +--- +${base_prompt} + +You are one of several independent reviewers in a Code Jury review. Read the change and report +findings in exactly the format the review prompt asks for. + +You are a reviewer, not the author: do not create, edit or delete files, do not stage or commit, and +do not work around the missing editing tools through the shell. The judge owns every change and +will act on your findings. Use the shell only to read: the diff, the history, the tests. diff --git a/lib/agents/kimi.json b/lib/agents/kimi.json index fe79dc1..73ccd2e 100644 --- a/lib/agents/kimi.json +++ b/lib/agents/kimi.json @@ -1,26 +1,43 @@ { "name": "kimi", - "product": "Moonshot Kimi CLI", + "product": "Moonshot Kimi Code CLI", "role": "reviewer", "enabled": false, "promptDelivery": "argv", "cwd": "worktree", "argv": [ "kimi", - "--quiet", - "--work-dir", - "{{worktree}}", - "--prompt", - "{{promptText}}" + "-p", + "{{promptText}}", + "--output-format", + "stream-json", + "--agent-file", + "{{packageDir}}/lib/agents/kimi-reviewer.md" ], - "sandbox": "none", - "sandboxNote": "print mode auto-approves every tool call; only the review prompt keeps it read-only", + "judgeArgv": [ + "kimi", + "-p", + "{{promptText}}", + "--output-format", + "stream-json" + ], + "sandbox": "tools", + "sandboxNote": "prompt mode auto-approves tool calls; the shipped reviewer profile removes the Edit and Write tools and allows only the read-only explore sub-agent, but the shell stays available", "resume": { - "supported": false, - "reason": "final text cannot reliably identify a session; replies start fresh with finding context" + "supported": true, + "argv": [ + "kimi", + "-p", + "{{promptText}}", + "--output-format", + "stream-json", + "--session", + "{{sessionId}}" + ], + "idFrom": "session_id\\W{0,3}((?:session_)?[0-9a-f]{8}-[0-9a-f-]{27})" }, - "report": "whole", - "expectSeconds": 400, - "install": "uv tool install --python 3.13 kimi-cli", - "docs": "https://github.com/MoonshotAI/kimi-cli" + "report": "kimi-json", + "expectSeconds": 600, + "install": "npm install -g @moonshot-ai/kimi-code", + "docs": "https://moonshotai.github.io/kimi-code/" } diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 40c7062..84ab1c5 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -13,7 +13,7 @@ const exec = (command, args, options = {}) => execFileSync(command, args, { enco try { const [packed] = JSON.parse(exec(npm, ['pack', '--json', '--pack-destination', temporary])); const files = packed.files.map(f => f.path); - for (const required of ['bin/jury.js', 'lib/review-group.js', 'web/index.html', 'jury.config.example.json', 'LICENSE', 'README.md', 'docs/usage.md', 'docs/console-demo.html']) assert.ok(files.includes(required), `missing ${required}`); + for (const required of ['bin/jury.js', 'lib/review-group.js', 'lib/agents/kimi-reviewer.md', 'web/index.html', 'jury.config.example.json', 'LICENSE', 'README.md', 'docs/usage.md', 'docs/console-demo.html']) assert.ok(files.includes(required), `missing ${required}`); assert.ok(!files.some(f => /(^|\/)(runs|node_modules|test|\.git)(\/|$)|(^|\/)\.(env|npmrc)$|(^|\/)jury\.config\.json$/.test(f)), 'private/development files in package'); exec(npm, ['install', '--prefix', temporary, '--ignore-scripts', '--no-audit', '--no-fund', path.join(temporary, packed.filename)]); // Exercise npm-created executable links through PATH, as users invoke them. @@ -31,7 +31,7 @@ try { assert.match(help, /--push=false/); assert.match(exec(process.execPath, [installed, 'help', '--all'], { cwd: temporary }), /jury review /); // Full related-PR path: subprocess agents, real local remotes, fixes, resume, failures. - exec(process.execPath, ['--test', 'test/cli-commands.test.js', 'test/global-judge.test.js', 'test/help.test.js', 'test/web-launch.test.js', 'test/review-group.test.js', 'test/reviewer-selection.test.js', 'test/automatic-roles.test.js', 'test/agy.test.js', 'test/opencode.test.js', 'test/new-agents.test.js'], { + exec(process.execPath, ['--test', 'test/cli-commands.test.js', 'test/global-judge.test.js', 'test/help.test.js', 'test/web-launch.test.js', 'test/review-group.test.js', 'test/reviewer-selection.test.js', 'test/automatic-roles.test.js', 'test/agy.test.js', 'test/opencode.test.js', 'test/new-agents.test.js', 'test/kimi.test.js'], { cwd: root, env: { ...isolated.env, JURY_TEST_CLI: installed }, maxBuffer: 8e6, }); console.log(`Verified installed @agentsdance/codejury@${version}: ${files.length} files, CLI commands/subcommands, executable links, saved console, and related-PR fixes/push/resume/failure tests.`); diff --git a/test/kimi.test.js b/test/kimi.test.js new file mode 100644 index 0000000..07b1dc0 --- /dev/null +++ b/test/kimi.test.js @@ -0,0 +1,217 @@ +// The built-in Kimi Code entry, driven through a stand-in `kimi`. Run with `node --test`. +// +// The stand-in prints exactly what Kimi Code CLI 0.39–0.42 prints in prompt +// mode with --output-format stream-json, so what is under test is Jury's side +// of the contract: the reviewer profile reaches the command line as a real file +// of the installed package, the report is the assistant's words alone, a tool +// result cannot end the loop, the session id is captured, and the reply resumes +// that session without the profile flag Kimi refuses next to --session. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm, access } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execFileSync, spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const cli = process.env.JURY_TEST_CLI ?? fileURLToPath(new URL("../bin/jury.js", import.meta.url)); +const root = path.dirname(path.dirname(cli)); +const { DEFAULTS, loadConfig, judgeAgent, knownAgents } = await import(pathToFileURL(path.join(root, "lib/config.js"))); +const { runAgent, hasStopToken } = await import(pathToFileURL(path.join(root, "lib/agents.js"))); +const { replyArgv } = await import(pathToFileURL(path.join(root, "lib/reply.js"))); +const { appendEvent, readEvents } = await import(pathToFileURL(path.join(root, "lib/store.js"))); + +const SESSION = "session_75050fde-14b3-43af-a479-2946f0f11375"; +const STOP = "NO NEW FINDINGS"; +// Verbatim from kimi 0.39.1 (0.42.0 adds retry notices with role "meta"): a +// version banner, an assistant turn that calls a tool, the tool's result — which +// quotes the prompt's stop-token line — the answer, and the resume hint. +const HINT = `{"role":"meta","type":"session.resume_hint","session_id":"${SESSION}","command":"kimi -r ${SESSION}","content":"To resume this session: kimi -r ${SESSION}"}`; + +// The stand-in records how it was called and answers with KIMI_STUB_ANSWER; +// KIMI_STUB_OUTPUT switches it to plain text or to a stream with no answer. +const STUB = `const fs = require("node:fs"); +const args = process.argv.slice(2); +const at = args.indexOf("--agent-file"); +fs.writeFileSync("call.json", JSON.stringify({ cwd: process.cwd(), args, + profile: at >= 0 ? { path: args[at + 1], exists: fs.existsSync(args[at + 1]) } : null })); +const answer = process.env.KIMI_STUB_ANSWER || ${JSON.stringify(STOP)}; +const json = (o) => JSON.stringify(o); +const lines = process.env.KIMI_STUB_OUTPUT === "text" ? ["• " + answer] + : process.env.KIMI_STUB_OUTPUT === "meta" ? [json({ role: "meta", type: "system.version", version: "0.39.1" })] + : [ + json({ role: "meta", type: "system.version", version: "0.39.1" }), + json({ role: "assistant", tool_calls: [{ type: "function", id: "Bash_0_ed8a0b21", function: { name: "Bash", arguments: json({ command: "grep -n 'NO NEW FINDINGS' prompt.md" }) } }] }), + json({ role: "tool", tool_call_id: "Bash_0_ed8a0b21", content: "40:say exactly \\"NO NEW FINDINGS\\" on its own line\\n" }), + json({ role: "assistant", content: answer }), + ${JSON.stringify(HINT)}, + ]; +process.stdout.write(lines.join("\\n") + "\\n"); +`; + +async function fixture(t) { + const dir = await mkdtemp(path.join(tmpdir(), "jury-kimi-")); + t.after(() => rm(dir, { recursive: true, force: true })); + const worktree = path.join(dir, "work tree"); + const bin = path.join(dir, "bin"); + await mkdir(worktree); await mkdir(bin); + const exe = path.join(bin, "kimi"); + await writeFile(exe, `#!${process.execPath}\n${STUB}`, { mode: 0o755 }); + const cfg = await loadConfig(worktree, { globalFile: path.join(dir, "absent") }); + const agent = knownAgents(cfg).find((a) => a.name === "kimi"); + const call = async () => JSON.parse(await readFile(path.join(worktree, "call.json"), "utf8")); + return { dir, worktree, bin, exe, cfg, agent, call }; +} + +test("kimi is an opt-in built-in whose review, judge and reply commands differ where they must", async (t) => { + const { cfg, agent } = await fixture(t); + assert.ok(agent, "kimi must be a known built-in agent"); + assert.ok(!cfg.agents.some((a) => a.name === "kimi"), "kimi ships opt-in"); + assert.equal(agent.report, "kimi-json"); + assert.equal(agent.cwd, "worktree", "Kimi Code has no --work-dir; it reviews where it is started"); + assert.equal(agent.sandbox, "tools"); + + // Prompt mode refuses --plan, --yolo and --auto, so none may appear anywhere. + const judge = judgeAgent(cfg, "kimi"); + for (const argv of [agent.argv, judge.argv, agent.resume.argv]) { + assert.equal(argv[0], "kimi"); + assert.ok(argv.includes("-p") && argv.includes("{{promptText}}")); + assert.deepEqual(argv.slice(argv.indexOf("--output-format"), argv.indexOf("--output-format") + 2), ["--output-format", "stream-json"]); + assert.ok(!argv.some((a) => ["--plan", "--yolo", "--auto", "--continue"].includes(a)), "refused next to -p, or resumes the wrong session"); + } + // The reviewer is boxed by the shipped profile; the judge writes without it. + const at = agent.argv.indexOf("--agent-file"); + assert.ok(at > 0); + assert.equal(agent.argv[at + 1], "{{packageDir}}/lib/agents/kimi-reviewer.md"); + assert.ok(!judge.argv.includes("--agent-file")); + // A resume names the exact session and, because Kimi refuses the pair, no profile. + assert.ok(agent.resume.supported); + assert.deepEqual(agent.resume.argv.slice(-2), ["--session", "{{sessionId}}"]); + assert.ok(!agent.resume.argv.includes("--agent-file")); + // The id is read from the trailing meta line Kimi prints on stdout, prefix and all: + // `kimi --session` wants the same string the hint shows. + assert.equal(HINT.match(new RegExp(agent.resume.idFrom, "i"))?.[1], SESSION); +}); + +test("the shipped reviewer profile is in the package and says what it removes", async () => { + const file = path.join(root, "lib", "agents", "kimi-reviewer.md"); + await access(file); + const text = (await readFile(file, "utf8")).replace(/\r/g, ""); + const [, front] = text.match(/^---\n([\s\S]*?)\n---\n/) ?? []; + assert.ok(front, "a frontmatter block is what Kimi reads the tool policy from"); + assert.match(front, /^description: .+/m, "Kimi requires a description"); + assert.match(front, /^name: [a-z0-9-]+$/m, "a non-kebab-case name is skipped with a warning"); + const list = (key) => (front.match(new RegExp(`^${key}:\\n((?: - .+\\n?)+)`, "m"))?.[1] ?? "").match(/- (.+)/g)?.map((s) => s.slice(2).trim()) ?? []; + assert.deepEqual(list("disallowedTools").sort(), ["Edit", "Write"]); + assert.deepEqual(list("subagents"), ["explore"], "the default coder sub-agent can write files"); + assert.match(text, /\$\{base_prompt\}/, "wrap Kimi's default prompt rather than replace it"); +}); + +test("a review is parsed from the JSON stream, its session captured, and its live output decoded", async (t) => { + const { worktree, exe, cfg, agent, call } = await fixture(t); + const prompt = 'Read this literally: "quotes"; $(touch unwanted)\nReview only.'; + const chunks = []; + const review = await runAgent({ + ...agent, argv: [exe, ...agent.argv.slice(1)], + env: { KIMI_STUB_ANSWER: "FINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay." }, + }, { worktree, prompt, stopToken: STOP, onChunk: (text) => chunks.push(text) }); + assert.equal(review.ok, true, review.report); + // The tool result quoted the stop token; only the assistant's words count. + assert.equal(review.verdict, "found"); + assert.equal(hasStopToken(review.report, STOP), false); + assert.equal(review.findings.length, 1); + assert.equal(review.findings[0].claim, "jitter is deletable"); + assert.equal(review.sessionId, SESSION, "the id the reply will resume"); + assert.match(review.raw, /system\.version/, "the raw artifact keeps the whole stream"); + + let made = await call(); + assert.equal(made.args[0], "-p"); + assert.equal(made.args[1], prompt, "the prompt arrives literally, as one argument"); + assert.deepEqual(made.args.slice(2, 4), ["--output-format", "stream-json"]); + assert.ok(path.isAbsolute(made.profile.path), "the profile must be an absolute path: the process starts in the worktree"); + assert.equal(made.profile.exists, true, "{{packageDir}} must point at the installed package"); + assert.equal(path.relative(root, made.profile.path).split(path.sep).join("/"), "lib/agents/kimi-reviewer.md"); + + // What the console saw while it ran: words and tool calls, no JSON envelope. + const live = chunks.join(""); + assert.match(live, /» Bash \{"command":"grep -n 'NO NEW FINDINGS' prompt.md"\}/); + assert.match(live, /FINDING: jitter is deletable/); + assert.doesNotMatch(live, /"role"|resume_hint|say exactly/); + + // The judge runs the same CLI with every tool: no profile on its command line. + const judge = judgeAgent(cfg, "kimi"); + const triage = await runAgent({ ...judge, argv: [exe, ...judge.argv.slice(1)] }, { worktree, prompt, stopToken: STOP }); + assert.equal(triage.verdict, "clean"); + made = await call(); + assert.equal(made.profile, null); + assert.deepEqual(made.args, ["-p", prompt, "--output-format", "stream-json"]); + + // And the reply goes to that exact session, without the profile flag. + const reply = replyArgv(agent, { promptText: "verdicts", worktree, sha: "abc", sessionId: review.sessionId }); + assert.equal(reply.resumed, true); + assert.deepEqual(reply.argv, ["kimi", "-p", "verdicts", "--output-format", "stream-json", "--session", SESSION]); + const fresh = replyArgv(agent, { promptText: "verdicts", worktree, sha: "abc" }); + assert.equal(fresh.resumed, false); + assert.ok(!fresh.argv.includes("--session")); +}); + +test("output that is not Kimi's JSON, or has no answer, is an error rather than a report", async (t) => { + const { worktree, exe, agent } = await fixture(t); + const invoke = (env) => runAgent({ ...agent, argv: [exe, ...agent.argv.slice(1)], env }, { worktree, prompt: "review", stopToken: STOP }); + // Text mode is what a customised argv without --output-format stream-json + // produces; its bullet-prefixed stop token must not be mistaken for clean. + const text = await invoke({ KIMI_STUB_OUTPUT: "text" }); + assert.equal(text.ok, false); + assert.equal(text.verdict, "error"); + assert.match(text.report, /Invalid Kimi Code JSON output/); + const silent = await invoke({ KIMI_STUB_OUTPUT: "meta" }); + assert.equal(silent.verdict, "error"); + assert.match(silent.report, /no assistant text/); +}); + +test("through the CLI: listed opt-in, run when named, and replied to in its recorded session", async (t) => { + const { dir, worktree, bin, agent, call } = await fixture(t); + const env = { ...process.env, HOME: dir, USERPROFILE: dir, PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + const command = (args) => spawnSync(process.execPath, [cli, ...args], { cwd: worktree, env, encoding: "utf8", timeout: 30000 }); + + assert.match(command(["agents"]).stdout, /ok\s+kimi\s+reviewer\s+\S+\s+\(opt-in\)/); + assert.equal(command(["agents", "judge", "kimi"]).status, 0); + assert.equal(JSON.parse(await readFile(path.join(dir, ".jury/config.json"))).judge, "kimi"); + assert.equal(command(["agents", "judge", "--reset"]).status, 0); + + const git = (args) => execFileSync("git", args, { cwd: worktree, env, stdio: "ignore" }); + git(["init", "-q", "-b", "master"]); + git(["-c", "user.name=Fixture", "-c", "user.email=fixture@example.com", "commit", "--allow-empty", "-qm", "base"]); + git(["checkout", "-qb", "feature"]); + // Keep one mock judge and this actual built-in registry entry. Never launch another installed agent. + await writeFile(path.join(worktree, "jury.config.json"), JSON.stringify({ agents: [ + ...DEFAULTS.agents.filter((a) => a.name !== "kimi").map(({ name }) => ({ name, enabled: false })), + { name: "fixture-judge", role: "main", argv: [process.execPath, "-e", "console.log('NO NEW FINDINGS')"] }, + ] })); + + const review = command(["review", "--dir", worktree, "--trunk", "master", "--web=false", "--push=false", "--rounds", "1", "--reviewer", "kimi"]); + assert.equal(review.status, 0, review.stderr); + assert.match(review.stdout, /REVIEW COMPLETE/); + let made = await call(); + assert.equal(made.profile.exists, true, "the profile reached a real file from the installed CLI"); + assert.deepEqual(made.args.slice(-4, -2), ["--output-format", "stream-json"]); + + const [slug] = await readdir(path.join(worktree, "runs")); + const runDir = path.join(worktree, "runs", slug); + const report = (await readEvents(runDir)).filter((e) => e.t === "agent.report" && e.agent === "kimi").at(-1); + assert.equal(report.sessionId, SESSION); + assert.equal(report.verdict, "clean"); + + await appendEvent(runDir, { t: "finding.raised", id: "reply-check", round: 1, agent: "kimi", claim: "fixture claim", body: "original finding context" }); + await appendEvent(runDir, { t: "finding.resolved", id: "reply-check", verdict: "rejected", reason: "fixture verdict" }); + const reply = command(["reply", "--dir", worktree, "--run", slug, "--jury", "kimi"]); + assert.equal(reply.status, 0, reply.stderr); + made = await call(); + assert.equal(made.args[made.args.indexOf("--session") + 1], SESSION, "the reply resumes the session the review recorded"); + assert.equal(made.profile, null, "Kimi refuses --agent-file next to --session; the session keeps its agent"); + assert.ok(!made.args.some((a) => a.includes("original finding context")), "a resumed session already holds its review"); + + const self = command(["review", "--dir", worktree, "--trunk", "master", "--web=false", "--push=false", "--judge", "kimi", "--reviewer", "kimi"]); + assert.notEqual(self.status, 0); + assert.match(self.stderr, /selected judge and cannot review its own work/); +}); diff --git a/test/new-agents.test.js b/test/new-agents.test.js index f710225..8b59cbc 100644 --- a/test/new-agents.test.js +++ b/test/new-agents.test.js @@ -116,24 +116,6 @@ const cases = [ "@prompt" ], "output": "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\":\"NO NEW FINDINGS\"}" - }, - { - "name": "kimi", - "bin": "kimi", - "review": [ - "--quiet", - "--work-dir", - "@cwd", - "--prompt", - "@prompt" - ], - "judge": [ - "--quiet", - "--work-dir", - "@cwd", - "--prompt", - "@prompt" - ] } ]; diff --git a/test/parse.test.js b/test/parse.test.js index d57a6a2..7e3f914 100644 --- a/test/parse.test.js +++ b/test/parse.test.js @@ -137,3 +137,33 @@ test("nonzero agent exit with a stop token is an error, never approval", async ( assert.equal(result.verdict, "error"); assert.match(result.report, /quota exhausted/); }); + +test("Kimi Code's JSON lines are read as the assistant's words only", () => { + // The shape kimi prints with --output-format stream-json (0.39.1). The tool + // result quotes the prompt's stop-token instruction, and the meta lines carry + // the version and the session hint; none of that is the report. + const stream = [ + { role: "meta", type: "system.version", version: "0.39.1" }, + { role: "assistant", content: "Reading the diff first.", tool_calls: [{ type: "function", id: "Bash_0", function: { name: "Bash", arguments: JSON.stringify({ command: "grep -n FINDINGS prompt.md" }) } }] }, + { role: "tool", tool_call_id: "Bash_0", content: '40:say exactly "NO NEW FINDINGS" on its own line\n' }, + { role: "meta", type: "turn.step.retrying", failed_attempt: 1, next_attempt: 2 }, + { role: "assistant", content: "FINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay." }, + { role: "meta", type: "session.resume_hint", session_id: "session_75050fde-14b3-43af-a479-2946f0f11375" }, + ].map((o) => JSON.stringify(o)).join("\n") + "\n"; + const report = extractReport(stream, "kimi-json"); + assert.equal(report, "Reading the diff first.\n\nFINDING: jitter is deletable\nWHERE: main_test.go:2237\n\nEvery assertion accepts the nominal delay."); + assert.equal(hasStopToken(report, "NO NEW FINDINGS"), false, "a tool result must not end the loop"); + const [f] = parseFindings(report); + assert.equal(f.claim, "jitter is deletable"); + assert.equal(f.loc, "main_test.go:2237"); + assert.doesNotMatch(report, /session_75050fde|system\.version|retrying/); + + // Typed content parts are read too. + const parts = JSON.stringify({ role: "assistant", content: [{ type: "text", text: "NO NEW FINDINGS" }] }); + assert.equal(extractReport(`${parts}\n`, "kimi-json"), "NO NEW FINDINGS"); + + // Plain text means the agent was not run in stream-json mode, and a stream + // with no answer never reviewed anything. Neither may be read as a report. + assert.throws(() => extractReport("• NO NEW FINDINGS\n", "kimi-json"), /Invalid Kimi Code JSON output/); + assert.throws(() => extractReport(`${JSON.stringify({ role: "meta", type: "system.version" })}\n`, "kimi-json"), /no assistant text/); +}); diff --git a/web/index.html b/web/index.html index 465e7df..7402594 100644 --- a/web/index.html +++ b/web/index.html @@ -5,7 +5,7 @@ --ground:#F4F6F8; --surface:#FFFFFF; --surface-alt:#EDF0F4; --line:#D7DDE4; --line-soft:#E6EAEF; --ink:#14181D; --ink-2:#39434F; --muted:#5A6572; --accent:#3A6B8A; --accent-soft:#E2ECF3; --main-hue:#6A4E8C; --main-soft:#EDE6F4; - --codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6; + --codex:#3A6B8A; --droid:#1F7A6B; --droid-soft:#DDF0EC; --agy:#4E7BD6; --kimi:#8A5A2B; --ok:#2E7D5B; --ok-soft:#E0F0E8; --warn:#B07A2B; --warn-soft:#F6ECD9; --bad:#A8443A; --bad-soft:#F6E3E0; --shadow:0 1px 2px rgba(20,24,29,.05), 0 8px 24px -14px rgba(20,24,29,.20); @@ -18,7 +18,7 @@ --ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B; --line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2; --accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31; - --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; + --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; --kimi:#D9A66E; --ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013; --bad:#E08076; --bad-soft:#2B1816; --shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75); @@ -28,7 +28,7 @@ --ground:#0E1216; --surface:#161C23; --surface-alt:#1C232B; --line:#2B343E; --line-soft:#222A33; --ink:#E4E9EE; --ink-2:#B4BEC9; --muted:#8593A2; --accent:#7FB0D0; --accent-soft:#1A2B38; --main-hue:#B49AD4; --main-soft:#241C31; - --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; + --codex:#7FB0D0; --droid:#63C4B0; --droid-soft:#142C28; --agy:#8FB4F5; --kimi:#D9A66E; --ok:#6FC49B; --ok-soft:#15271E; --warn:#D9A857; --warn-soft:#292013; --bad:#E08076; --bad-soft:#2B1816; --shadow:0 1px 2px rgba(0,0,0,.45), 0 10px 28px -16px rgba(0,0,0,.75); @@ -149,6 +149,7 @@ .seg[data-a="droid"] { background-color:var(--droid); } .seg[data-a="main"], .seg[data-a="claude"] { background-color:var(--main-hue); } .seg[data-a="agy"] { background-color:var(--accent); } + .seg[data-a="kimi"] { background-color:var(--kimi); } .lane-durations { grid-column:2; display:flex; flex-wrap:wrap; gap:6px 16px; font-family:var(--mono); font-size:11px; line-height:1.6; color:var(--muted); } .lane-duration { white-space:nowrap; } @@ -636,6 +637,10 @@

What shipped

// Factory's own mark, taken from factory.ai's nav logo. droid: '', + // Kimi's mark (simple-icons, CC0 icon set; the mark itself remains Moonshot + // AI's trademark and is used here only to identify the tool). + kimi: '', }; /** * A mark for an agent with no official one. @@ -1184,7 +1189,7 @@

What shipped

// chips — and so the same agent keeps its colour across reloads. const HUES = { claude: "var(--main-hue)", codex: "var(--codex)", droid: "var(--droid)", - grok: "var(--ink-2)", agy: "var(--agy)", + grok: "var(--ink-2)", agy: "var(--agy)", kimi: "var(--kimi)", }; function hueFor(name) { if (HUES[name]) return HUES[name];