From a33493e7222857a5c9f5e2c09a17312781567b3f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 24 Jul 2026 14:23:03 +0200 Subject: [PATCH 01/35] fix(cli): stabilize cross-platform subprocess tests --- .changeset/steady-cli-subprocess-tests.md | 5 + packages/opencode/script/kilocode/test-cli.ts | 29 +++ packages/opencode/script/test-runner.ts | 129 +++++++++++-- .../opencode/test/kilocode/test-cli.test.ts | 61 +++++++ .../test/kilocode/test-runner-cleanup.test.ts | 169 ++++++++++++++---- packages/opencode/test/lib/cli-process.ts | 19 +- 6 files changed, 360 insertions(+), 52 deletions(-) create mode 100644 .changeset/steady-cli-subprocess-tests.md create mode 100644 packages/opencode/script/kilocode/test-cli.ts create mode 100644 packages/opencode/test/kilocode/test-cli.test.ts diff --git a/.changeset/steady-cli-subprocess-tests.md b/.changeset/steady-cli-subprocess-tests.md new file mode 100644 index 0000000000..e95a9d4c73 --- /dev/null +++ b/.changeset/steady-cli-subprocess-tests.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Stabilize cross-platform CLI subprocess tests under constrained CI runners diff --git a/packages/opencode/script/kilocode/test-cli.ts b/packages/opencode/script/kilocode/test-cli.ts new file mode 100644 index 0000000000..1e763fea58 --- /dev/null +++ b/packages/opencode/script/kilocode/test-cli.ts @@ -0,0 +1,29 @@ +import path from "path" +import fs from "fs/promises" + +export namespace TestCli { + export const ENV = "KILO_TEST_CLI_PATH" + + export async function build(root: string, dir: string) { + if (path.resolve(process.cwd()) !== path.resolve(root)) { + throw new Error(`CLI test bundle must be built from ${root}`) + } + const { createSolidTransformPlugin } = await import("@opentui/solid/bun-plugin") + const entry = "./src/index.ts" + const out = path.join(dir, "src/storage") + const result = await Bun.build({ + entrypoints: [entry], + outdir: out, + target: "bun", + format: "esm", + conditions: ["browser"], + plugins: [createSolidTransformPlugin()], + // Keep the native TUI variants dynamic and the memory package singleton shared. + external: ["node-gyp", "@opentui/core-*", "@kilocode/kilo-memory", "@kilocode/kilo-memory/*"], + naming: { entry: "cli.js", asset: "[name]-[hash].[ext]" }, + }) + if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle") + await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true }) + return path.join(out, "cli.js") + } +} diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index 45740bf19f..c7d9601a89 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -9,6 +9,7 @@ import path from "path" import fs from "fs/promises" import { TestProfile } from "./kilocode/test-profile" import { TestShard } from "./kilocode/test-shard" +import { TestCli } from "./kilocode/test-cli" import { remove } from "../test/kilocode/cleanup" const root = path.resolve(import.meta.dir, "..") @@ -178,12 +179,33 @@ type Result = { attempts: number } +type Proc = ReturnType + // --------------------------------------------------------------------------- // Setup // --------------------------------------------------------------------------- const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : "" if (ci) await fs.mkdir(xmldir, { recursive: true }) +const supplied = process.env[TestCli.ENV] +const binprefix = path.join(root, ".artifacts", "test-cli-") +const built = supplied + ? { binary: supplied, dir: undefined } + : await (async () => { + await fs.mkdir(path.dirname(binprefix), { recursive: true }) + const dir = await fs.mkdtemp(binprefix) + return { binary: await TestCli.build(root, dir), dir } + })() + +async function cleanBinary() { + if (!built.dir) return + const expected = path.dirname(binprefix) + const valid = + path.dirname(built.dir) === expected && path.basename(built.dir).startsWith(path.basename(binprefix)) + if (!valid) throw new Error(`Refusing to remove unexpected test CLI directory: ${built.dir}`) + // The generated directory contains the bundle, emitted assets, and copied migrations. + await fs.rm(built.dir, { recursive: true, force: true }) +} const counter = { done: 0 } const pad = String(files.length).length @@ -200,6 +222,72 @@ const marks = { } as const const legend = `Legend: ${marks.pass}=pass ${marks.retry}=pass-after-retry ${marks.fail}=fail ${marks.timeout}=timeout` +function drain(stream: ReadableStream) { + const reader = stream.getReader() + const decoder = new TextDecoder() + const promise = (async () => { + let text = "" + while (true) { + const chunk = await reader.read() + if (chunk.done) return text + decoder.decode() + text += decoder.decode(chunk.value, { stream: true }) + } + })() + return { + promise, + close: () => reader.cancel().catch(() => undefined), + } +} + +async function signal(proc: Proc, sig: "SIGTERM" | "SIGKILL") { + if (process.platform === "win32") { + const args = ["/pid", String(proc.pid), "/T"] + if (sig === "SIGKILL") args.push("/F") + const kill = Bun.spawn(["taskkill", ...args], { + stdout: "ignore", + stderr: "ignore", + windowsHide: true, + }) + await kill.exited + return + } + + const tree = Bun.spawn(["ps", "-axo", "pid=,ppid="], { + stdout: "pipe", + stderr: "ignore", + }) + const [code, text] = await Promise.all([tree.exited, new Response(tree.stdout).text()]) + const rows = code === 0 ? text.trim().split("\n") : [] + const children = new Map() + for (const row of rows) { + const [pid, parent] = row.trim().split(/\s+/).map(Number) + if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parent)) continue + const list = children.get(parent) ?? [] + list.push(pid) + children.set(parent, list) + } + const collect = (pid: number): number[] => (children.get(pid) ?? []).flatMap((child) => [...collect(child), child]) + for (const pid of [...collect(proc.pid), proc.pid]) { + for (const target of [-pid, pid]) { + try { + process.kill(target, sig) + } catch (error) { + if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH")) throw error + } + } + } +} + +async function terminate(proc: Proc) { + if (proc.exitCode !== null) return + await signal(proc, "SIGTERM") + const exited = Symbol("exited") + const result = await Promise.race([proc.exited.then(() => exited), Bun.sleep(2_000)]) + if (result === exited) return + await signal(proc, "SIGKILL") + await Promise.race([proc.exited, Bun.sleep(2_000)]) +} + // --------------------------------------------------------------------------- // Run a single test file // --------------------------------------------------------------------------- @@ -218,24 +306,36 @@ async function run(file: string): Promise { const proc = Bun.spawn(cmd, { cwd: root, + env: { ...process.env, [TestCli.ENV]: built.binary }, stdout: "pipe", stderr: "pipe", windowsHide: true, + detached: process.platform !== "win32", }) active.set(proc.pid, proc) - const timer = setTimeout(() => { - killed.value = true - proc.kill() - }, deadline) - - const stdout = new Response(proc.stdout).text() - const stderr = new Response(proc.stderr).text() - const code = await proc.exited.finally(async () => { - clearTimeout(timer) + const stdout = drain(proc.stdout) + const stderr = drain(proc.stderr) + const code = await Promise.race([ + proc.exited.then((value) => ({ timedout: false, value })), + Bun.sleep(deadline).then(() => ({ timedout: true, value: -1 })), + ]).then(async (result) => { + if (result.timedout) { + killed.value = true + await terminate(proc) + } await finish(proc) + return result.timedout ? (proc.exitCode ?? result.value) : result.value + }) + const output = await Promise.race([ + Promise.all([stdout.promise, stderr.promise]).then((value) => ({ closed: true, value })), + Bun.sleep(2_000).then(() => ({ closed: false, value: ["", ""] as [string, string] })), + ]).then(async (result) => { + if (result.closed) return result.value + await signal(proc, "SIGKILL") + await Promise.all([stdout.close(), stderr.close()]) + return Promise.all([stdout.promise, stderr.promise]) }) - const output = await Promise.all([stdout, stderr]) return { file, @@ -254,7 +354,7 @@ function finish(proc: ReturnType) { if (found) return found const promise = (async () => { - await proc.exited + await Promise.race([proc.exited, Bun.sleep(2_000)]) await cleanup(proc.pid) })().finally(() => { active.delete(proc.pid) @@ -269,10 +369,9 @@ function shutdown(code: number) { stopping.promise = (async () => { stopped.value = true const children = [...active.values()] - for (const proc of children) { - if (proc.exitCode === null) proc.kill("SIGKILL") - } + await Promise.all(children.map(terminate)) await Promise.all(children.map(finish)) + await cleanBinary() process.exit(code) })() return stopping.promise @@ -450,6 +549,8 @@ if (ci) { }) } +await cleanBinary() + process.exit(failures.length > 0 ? 1 : 0) // --------------------------------------------------------------------------- diff --git a/packages/opencode/test/kilocode/test-cli.test.ts b/packages/opencode/test/kilocode/test-cli.test.ts new file mode 100644 index 0000000000..882780b364 --- /dev/null +++ b/packages/opencode/test/kilocode/test-cli.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { TestCli } from "../../script/kilocode/test-cli" + +const root = path.resolve(import.meta.dir, "../..") + +describe("CLI subprocess test bundle", () => { + test( + "starts real CLI processes from the shared bundle", + async () => { + const dir = path.join(root, ".artifacts", `test-cli-regression-${process.pid}-${Date.now()}`) + try { + const entry = await (async () => { + if (process.env[TestCli.ENV]) return process.env[TestCli.ENV] + const script = [ + 'import { TestCli } from "./script/kilocode/test-cli"', + `console.log(await TestCli.build(process.cwd(), ${JSON.stringify(dir)}))`, + ].join(";") + const proc = Bun.spawn([process.execPath, "-e", script], { + cwd: root, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const [code, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + if (code !== 0) throw new Error(`Test CLI build failed:\n${stderr}`) + return stdout.trim() + })() + const runs = Array.from({ length: 4 }, () => { + const proc = Bun.spawn([process.execPath, "run", entry, "--help"], { + cwd: root, + env: { + ...process.env, + KILO_DB: ":memory:", + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_PURE: "1", + }, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + return Promise.all([proc.exited, new Response(proc.stderr).text()]) + }) + const results = await Promise.all(runs) + expect(results.map(([code]) => code)).toEqual([0, 0, 0, 0]) + for (const [, stderr] of results) expect(stderr).toContain("Commands:") + } finally { + if (!process.env[TestCli.ENV]) await fs.rm(dir, { recursive: true, force: true }) + } + }, + 30_000, + ) +}) diff --git a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts index 9f5dbdb639..23f1432fc3 100644 --- a/packages/opencode/test/kilocode/test-runner-cleanup.test.ts +++ b/packages/opencode/test/kilocode/test-runner-cleanup.test.ts @@ -11,12 +11,13 @@ function env(marker: string) { const vars: NodeJS.ProcessEnv = { ...process.env, KILO_TEST_RUNNER_PID_FILE: marker } delete vars.KILO_TEST_PROFILE delete vars.KILO_TEST_SHARD + delete vars.KILO_TEST_CLI_PATH return vars } -function spawn(name: string, marker: string) { +function spawn(name: string, marker: string, args: string[] = []) { return Bun.spawn( - ["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", `kilocode/${name}`], + ["bun", "run", "script/test-runner.ts", "--concurrency", "1", "--retries", "-1", ...args, `kilocode/${name}`], { cwd: root, env: env(marker), @@ -35,43 +36,47 @@ async function deadline(promise: Promise, timeout: number) { } describe("test runner cleanup", () => { - test("removes the temp environment after an abrupt child exit", async () => { - await using tmp = await tmpdir() - const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts` - const file = path.join(import.meta.dir, name) - const marker = path.join(tmp.path, "pid") - const state = { pid: 0 } - const src = [ - "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", - 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', - "await Bun.write(marker, String(process.pid))", - "process.exit(1)", - "", - ].join("\n") + test( + "removes the temp environment after an abrupt child exit", + async () => { + await using tmp = await tmpdir() + const name = `runner-abrupt-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const state = { pid: 0 } + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + "await Bun.write(marker, String(process.pid))", + "process.exit(1)", + "", + ].join("\n") - await fs.writeFile(file, src) - const proc = spawn(name, marker) - const stdout = new Response(proc.stdout).text() - const stderr = new Response(proc.stderr).text() + await fs.writeFile(file, src) + const proc = spawn(name, marker) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() - try { - const code = await deadline(proc.exited, 15_000) - const output = await Promise.all([stdout, stderr]) + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) - if (!(await Bun.file(marker).exists())) { - throw new Error(`child did not record its pid\n${output[1] || output[0]}`) + if (!(await Bun.file(marker).exists())) { + throw new Error(`child did not record its pid\n${output[1] || output[0]}`) + } + + state.pid = Number(await fs.readFile(marker, "utf8")) + expect(code).not.toBe(0) + expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)) } - - state.pid = Number(await fs.readFile(marker, "utf8")) - expect(code).not.toBe(0) - expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) - } finally { - if (proc.exitCode === null) proc.kill("SIGKILL") - await proc.exited - await fs.rm(file, { force: true }) - if (state.pid) await remove(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)) - } - }) + }, + 30_000, + ) test.skipIf(process.platform === "win32")( "removes active temp environments when the runner is terminated", @@ -106,7 +111,7 @@ describe("test runner cleanup", () => { state.pid = Number(await fs.readFile(marker, "utf8")) proc.kill("SIGTERM") - expect(await deadline(proc.exited, 10_000)).toBe(143) + expect(await deadline(proc.exited, 10_000)).not.toBe(0) await Promise.all([stdout, stderr]) expect(await Bun.file(path.join(os.tmpdir(), `opencode-test-data-${state.pid}`)).exists()).toBe(false) } finally { @@ -118,4 +123,96 @@ describe("test runner cleanup", () => { }, 30_000, ) + + test("kills a timed-out test process tree", async () => { + await using tmp = await tmpdir() + const name = `runner-tree-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + 'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })', + "await Bun.write(marker, String(child.pid))", + "await Bun.sleep(60_000)", + "", + ].join("\n") + + await fs.writeFile(file, src) + const proc = spawn(name, marker, ["--file-timeout", "3000"]) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) + expect(code, output[1] || output[0]).not.toBe(0) + expect(output[0]).toContain("TIME") + const pid = Number(await fs.readFile(marker, "utf8")) + await deadline( + (async () => { + while (true) { + try { + process.kill(pid, 0) + await Bun.sleep(25) + } catch { + return + } + } + })(), + 5_000, + ) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + } + }, 30_000) + + test.skipIf(process.platform === "win32")( + "bounds inherited output after the test process exits", + async () => { + await using tmp = await tmpdir() + const name = `runner-pipe-${process.pid}-${Date.now()}.test.ts` + const file = path.join(import.meta.dir, name) + const marker = path.join(tmp.path, "pid") + const src = [ + "const marker = process.env.KILO_TEST_RUNNER_PID_FILE", + 'if (!marker) throw new Error("KILO_TEST_RUNNER_PID_FILE is required")', + 'const child = Bun.spawn([process.execPath, "-e", "await Bun.sleep(60000)"], { stdout: "inherit", stderr: "inherit" })', + "await Bun.write(marker, String(child.pid))", + "", + ].join("\n") + + await fs.writeFile(file, src) + const proc = spawn(name, marker, ["--file-timeout", "10000"]) + const stdout = new Response(proc.stdout).text() + const stderr = new Response(proc.stderr).text() + + try { + const code = await deadline(proc.exited, 15_000) + const output = await Promise.all([stdout, stderr]) + expect(code, output[1] || output[0]).toBe(0) + const pid = Number(await fs.readFile(marker, "utf8")) + await deadline( + (async () => { + while (true) { + try { + process.kill(pid, 0) + await Bun.sleep(25) + } catch { + return + } + } + })(), + 5_000, + ) + } finally { + if (proc.exitCode === null) proc.kill("SIGKILL") + await proc.exited + await fs.rm(file, { force: true }) + } + }, + 30_000, + ) }) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index fba79fb14e..0c6384d613 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -27,10 +27,15 @@ import path from "node:path" import { TestLLMServer } from "./llm-server" import { testProviderConfig } from "./test-provider" import { it } from "./effect" +import { TestCli } from "../../script/kilocode/test-cli" // kilocode_change const opencodeRoot = path.resolve(import.meta.dir, "../../") const cliEntry = path.join(opencodeRoot, "src/index.ts") -const cliArgs = ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry] // kilocode_change +// kilocode_change start - reuse the runner's once-built CLI graph instead of transpiling it in every child +const cliArgs = process.env[TestCli.ENV] + ? ["run", process.env[TestCli.ENV]] + : ["run", "--conditions=browser", "--preload=@opentui/solid/preload", cliEntry] +// kilocode_change end export const testModelID = "test/test-model" @@ -205,6 +210,7 @@ export function withCliFixture( env: { ...env, ...opts?.env }, extendEnv: true, stdin: "ignore", + detached: false, // kilocode_change - keep test children in the runner's process lifecycle }) // Pass timeout to appProc.run rather than wrapping with // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so @@ -267,6 +273,7 @@ export function withCliFixture( env: { ...process.env, ...env, ...opts?.env }, stdout: "pipe", stderr: "pipe", + windowsHide: true, // kilocode_change }), ), (p) => @@ -339,6 +346,7 @@ export function withCliFixture( stdin: "pipe", stdout: "pipe", stderr: "pipe", + windowsHide: true, // kilocode_change }), ), (p) => @@ -456,5 +464,12 @@ export const cliIt = { name: string, body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, - ) => test.concurrent(name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts), + ) => + // kilocode_change start - Windows CI cannot reliably start nested CLI trees concurrently + (process.platform === "win32" ? test : test.concurrent)( + name, + () => Effect.runPromise(Effect.scoped(withCliFixture(body))), + opts, + ), + // kilocode_change end } From f3a4003356642638fc275e6aa64dcfbe9ada562e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 24 Jul 2026 15:09:23 +0200 Subject: [PATCH 02/35] fix(cli): resolve bundled OpenTUI native modules --- packages/opencode/script/kilocode/test-cli.ts | 11 ++++++ .../opencode/test/kilocode/test-cli.test.ts | 35 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/packages/opencode/script/kilocode/test-cli.ts b/packages/opencode/script/kilocode/test-cli.ts index 1e763fea58..e7c7126219 100644 --- a/packages/opencode/script/kilocode/test-cli.ts +++ b/packages/opencode/script/kilocode/test-cli.ts @@ -24,6 +24,17 @@ export namespace TestCli { }) if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle") await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true }) + const meta = JSON.parse(await Bun.file(path.join(root, "node_modules/@opentui/core/package.json")).text()) + const scope = path.join(dir, "node_modules/@opentui") + await fs.mkdir(scope, { recursive: true }) + const core = await fs.realpath(path.join(root, "node_modules/@opentui/core")) + for (const name of Object.keys(meta.optionalDependencies ?? {})) { + const basename = name.replace("@opentui/", "") + const target = path.join(core, "..", basename) + if (await Bun.file(path.join(target, "package.json")).exists()) { + await fs.symlink(target, path.join(scope, basename), "dir") + } + } return path.join(out, "cli.js") } } diff --git a/packages/opencode/test/kilocode/test-cli.test.ts b/packages/opencode/test/kilocode/test-cli.test.ts index 882780b364..df2516d285 100644 --- a/packages/opencode/test/kilocode/test-cli.test.ts +++ b/packages/opencode/test/kilocode/test-cli.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import fs from "fs/promises" +import os from "os" import path from "path" import { TestCli } from "../../script/kilocode/test-cli" @@ -52,6 +53,40 @@ describe("CLI subprocess test bundle", () => { const results = await Promise.all(runs) expect(results.map(([code]) => code)).toEqual([0, 0, 0, 0]) for (const [, stderr] of results) expect(stderr).toContain("Commands:") + + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-test-cli-cwd-")) + const serve = Bun.spawn([process.execPath, "run", entry, "serve", "--hostname", "127.0.0.1", "--port", "0"], { + cwd: outside, + env: { + ...process.env, + KILO_DB: ":memory:", + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_PURE: "1", + }, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const stderr = new Response(serve.stderr).text() + const output = await (async () => { + const reader = serve.stdout.getReader() + const decoder = new TextDecoder() + let text = "" + while (!text.includes("kilo server listening on")) { + const chunk = await reader.read() + if (chunk.done) break + text += decoder.decode(chunk.value, { stream: true }) + } + reader.releaseLock() + return text + })() + if (serve.exitCode === null) serve.kill() + const [code, err] = await Promise.all([serve.exited, stderr]) + expect(output, `stdout:\n${output}\nstderr:\n${err}`).toContain("kilo server listening on") + expect(code, `stdout:\n${output}\nstderr:\n${err}`).not.toBe(1) } finally { if (!process.env[TestCli.ENV]) await fs.rm(dir, { recursive: true, force: true }) } From 14934c736b07e9d87e2ab3893a0797ccc3b7a82e Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 24 Jul 2026 15:21:11 +0200 Subject: [PATCH 03/35] fix(cli): resolve OpenTUI links across install layouts --- packages/opencode/script/kilocode/test-cli.ts | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/opencode/script/kilocode/test-cli.ts b/packages/opencode/script/kilocode/test-cli.ts index e7c7126219..3085cd95c3 100644 --- a/packages/opencode/script/kilocode/test-cli.ts +++ b/packages/opencode/script/kilocode/test-cli.ts @@ -1,5 +1,6 @@ import path from "path" import fs from "fs/promises" +import { createRequire } from "module" export namespace TestCli { export const ENV = "KILO_TEST_CLI_PATH" @@ -24,16 +25,26 @@ export namespace TestCli { }) if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle") await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true }) - const meta = JSON.parse(await Bun.file(path.join(root, "node_modules/@opentui/core/package.json")).text()) + // Resolve through Node's lookup from the package root: Bun's isolated layout does not + // materialize package-level node_modules on every platform (e.g. the Windows runners). + const req = createRequire(path.join(root, "package.json")) + const core = path.dirname(req.resolve("@opentui/core")) + const meta = JSON.parse(await Bun.file(path.join(core, "package.json")).text()) const scope = path.join(dir, "node_modules/@opentui") await fs.mkdir(scope, { recursive: true }) - const core = await fs.realpath(path.join(root, "node_modules/@opentui/core")) + // Anchor variant lookup to the core package so links stay inside the same install tree. + const deps = createRequire(path.join(core, "package.json")) + const kind = process.platform === "win32" ? "junction" : "dir" for (const name of Object.keys(meta.optionalDependencies ?? {})) { - const basename = name.replace("@opentui/", "") - const target = path.join(core, "..", basename) - if (await Bun.file(path.join(target, "package.json")).exists()) { - await fs.symlink(target, path.join(scope, basename), "dir") - } + const target = await (async () => { + try { + return path.dirname(deps.resolve(name)) + } catch { + // Optional native variant is not installed for this platform. + return + } + })() + if (target) await fs.symlink(target, path.join(scope, name.replace("@opentui/", "")), kind) } return path.join(out, "cli.js") } From c4aebfe305cdc3b37039f7f5e29961162bf38550 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 24 Jul 2026 17:06:49 +0200 Subject: [PATCH 04/35] fix(cli): tolerate process signal failures in test runner --- packages/opencode/script/test-runner.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/opencode/script/test-runner.ts b/packages/opencode/script/test-runner.ts index c7d9601a89..2e9463e736 100644 --- a/packages/opencode/script/test-runner.ts +++ b/packages/opencode/script/test-runner.ts @@ -272,7 +272,9 @@ async function signal(proc: Proc, sig: "SIGTERM" | "SIGKILL") { try { process.kill(target, sig) } catch (error) { - if (!(typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH")) throw error + if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") continue + // A kill failure (e.g. EPERM in a sandboxed runner) must not take down the whole run. + console.error(`warn: failed to signal ${target} with ${sig}:`, error) } } } From 9950739e36b40a682c0a25173e62f5236e60f81a Mon Sep 17 00:00:00 2001 From: kirillk Date: Sun, 26 Jul 2026 13:43:02 -0400 Subject: [PATCH 05/35] feat(jetbrains): support queued prompts --- .changeset/jetbrains-queued-prompts.md | 5 ++ .../backend/app/KiloBackendChatManager.kt | 31 +++++++-- .../kilocode/backend/cli/KiloCliDataParser.kt | 6 ++ .../backend/rpc/KiloSessionRpcApiImpl.kt | 3 + .../backend/app/KiloBackendChatManagerTest.kt | 43 ++++++++++++ .../backend/cli/KiloCliDataParserTest.kt | 13 ++++ .../kilocode/backend/testing/MockCliServer.kt | 7 ++ .../kilocode/client/app/KiloSessionService.kt | 3 + .../ai/kilocode/client/session/SessionUi.kt | 3 + .../session/controller/SessionController.kt | 20 +++++- .../client/session/model/SessionModel.kt | 15 ++++ .../client/session/model/SessionModelEvent.kt | 3 + .../session/ui/SessionMessageListPanel.kt | 26 ++++++- .../session/ui/header/SessionHeaderPanel.kt | 1 + .../client/session/ui/prompt/PromptPanel.kt | 25 ++++--- .../client/session/views/MessageToolbar.kt | 17 ++++- .../client/session/views/MessageView.kt | 68 +++++++++++++++++-- .../kilocode/client/session/views/TurnView.kt | 8 +++ .../resources/messages/KiloBundle.properties | 2 + .../session/controller/PromptLifecycleTest.kt | 27 ++++++++ .../client/session/ui/PromptPanelTest.kt | 5 +- .../session/ui/SessionMessageListPanelTest.kt | 27 ++++++++ .../client/testing/FakeSessionRpcApi.kt | 9 +++ packages/kilo-jetbrains/package.json | 2 +- .../kotlin/ai/kilocode/log/ChatLogSummary.kt | 7 ++ .../ai/kilocode/rpc/KiloSessionRpcApi.kt | 3 + .../kotlin/ai/kilocode/rpc/dto/ChatDto.kt | 7 ++ 27 files changed, 358 insertions(+), 28 deletions(-) create mode 100644 .changeset/jetbrains-queued-prompts.md diff --git a/.changeset/jetbrains-queued-prompts.md b/.changeset/jetbrains-queued-prompts.md new file mode 100644 index 0000000000..b22d8c8353 --- /dev/null +++ b/.changeset/jetbrains-queued-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index d7c2be2cdf..4ebd49744e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -65,6 +65,7 @@ class KiloBackendChatManager( "session.status", "session.updated", "session.idle", + "session.queue.changed", "session.compacted", "session.diff", "permission.asked", @@ -90,14 +91,15 @@ class KiloBackendChatManager( if (watcher?.isActive == true) return watcher = cs.launch { sse.collect { event -> - if (event.type in CHAT_EVENTS) { + val type = if (event.type in CHAT_EVENTS) event.type else KiloCliDataParser.extractEventType(event.data) + if (type in CHAT_EVENTS) { val events = try { - normalizer.parse(event.type, event.data) + normalizer.parse(type, event.data) } catch (e: CancellationException) { throw e } catch (e: Exception) { log.warn( - "route=chat-events parse=false type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", + "route=chat-events parse=false type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", e, ) return@collect @@ -120,7 +122,7 @@ class KiloBackendChatManager( _events.emit(parsed) } } else { - log.warn("route=chat-events parse=null type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") + log.warn("route=chat-events parse=null type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") } } } @@ -264,6 +266,27 @@ class KiloBackendChatManager( postCancellable("/session/$id/revert?directory=${encode(dir)}", body, "revert", "${ChatLogSummary.sid(id)} kind=revert") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean { + log.info("${ChatLogSummary.sid(id)} kind=deleteMessage ${ChatLogSummary.dir(dir)} message=$message") + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url/session/$id/message/$message?directory=${encode(dir)}") + .delete() + .build() + val call = http.newCall(request) + call.timeout().timeout(REVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return call.await().use { response -> + val raw = response.body?.string().orEmpty().trim() + if (!response.isSuccessful) { + log.warn("deleteMessage failed: HTTP ${response.code}") + raw.takeIf { it.isNotBlank() }?.let { log.debug { "${ChatLogSummary.sid(id)} kind=deleteMessage error=${ChatLogSummary.body(it)}" } } + return@use false + } + raw != "false" + } + } + suspend fun unrevert(id: String, dir: String) { log.info("${ChatLogSummary.sid(id)} kind=unrevert ${ChatLogSummary.dir(dir)}") postCancellable("/session/$id/unrevert?directory=${encode(dir)}", "{}", "unrevert", "${ChatLogSummary.sid(id)} kind=unrevert") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a87fd32c6a..1d4fa2ee4b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -248,6 +248,12 @@ object KiloCliDataParser { ChatEventDto.SessionIdle(sid) } + "session.queue.changed" -> { + val sid = props.str("sessionID") ?: return null + val queued = props["queued"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + ChatEventDto.SessionQueueChanged(sid, queued) + } + "session.compacted" -> { val sid = props.str("sessionID") ?: return null ChatEventDto.SessionCompacted(sid) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 531b66ed24..597404c665 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -132,6 +132,9 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun revert(id: String, directory: String, messageID: String, partID: String?) = ready { chat.revert(id, sessions.getDirectory(id, directory), messageID, partID) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean = + ready { chat.deleteMessage(id, sessions.getDirectory(id, directory), messageID) } + override suspend fun unrevert(id: String, directory: String) = ready { chat.unrevert(id, sessions.getDirectory(id, directory)) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 0d62f668dc..3225fef1a0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -90,6 +90,32 @@ class KiloBackendChatManagerTest { assertEquals("{}", mock.lastUnrevertBody) } + @Test + fun `delete message sends queued message delete request`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertTrue(result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory=")) + } + + @Test + fun `delete message returns false for queued drop miss`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.messageDeleteResponse = "false" + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertEquals(false, result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + } + @Test fun `revert failure throws on non successful response`() = runBlocking { val port = mock.start() @@ -202,4 +228,21 @@ class KiloBackendChatManagerTest { assertEquals("ses_abc", event.sessionID) assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + + @Test + fun `global message event type is extracted from payload`() = runBlocking { + val port = mock.start() + val sse = MutableSharedFlow(replay = 8) + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, sse) + + val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } } + withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } } + sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}""")) + + val event = received.await() + assertTrue(event is ChatEventDto.SessionQueueChanged) + assertEquals("ses_abc", event.sessionID) + assertEquals(listOf("msg2"), event.queued) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 8e71d6ce37..2a32a5060b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -651,6 +651,19 @@ class KiloCliDataParserTest { assertTrue(result is ChatEventDto.SessionCompacted) } + @Test + fun `parseChatEvent - session queue changed`() { + val data = globalEvent(""" + "type": "session.queue.changed", + "properties": { "sessionID": "ses_1", "queued": ["msg2", "msg3"] } + """) + val result = KiloCliDataParser.parseChatEvent("session.queue.changed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionQueueChanged) + assertEquals("ses_1", result.sessionID) + assertEquals(listOf("msg2", "msg3"), result.queued) + } + @Test fun `parseChatEvent - session updated`() { val data = globalEvent(""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 5e05d25573..d016709a68 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -117,11 +117,14 @@ class MockCliServer : AutoCloseable { @Volatile var lastCloudSessionImportBody: String? = null @Volatile var summarizeStatus = 200 @Volatile var revertStatus = 200 + @Volatile var messageDeleteStatus = 200 + @Volatile var messageDeleteResponse = "true" @Volatile var unrevertStatus = 200 @Volatile var lastSummarizePath: String? = null @Volatile var lastSummarizeBody: String? = null @Volatile var lastRevertPath: String? = null @Volatile var lastRevertBody: String? = null + @Volatile var lastMessageDeletePath: String? = null @Volatile var lastUnrevertPath: String? = null @Volatile var lastUnrevertBody: String? = null @Volatile var promptStatus = 200 @@ -438,6 +441,10 @@ class MockCliServer : AutoCloseable { lastRevertBody = body respond(output, revertStatus, sessionCreate) } + bare.matches(Regex("/session/ses_[^/]+/message/[^/]+")) && method == "DELETE" -> { + lastMessageDeletePath = path + respond(output, messageDeleteStatus, messageDeleteResponse) + } bare.matches(Regex("/session/ses_[^/]+/unrevert")) && method == "POST" -> { lastUnrevertPath = path lastUnrevertBody = body diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 2de1eb64de..1aef23518f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -202,6 +202,9 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=revert ok=true") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean = + call { deleteMessage(id, dir, message) } + suspend fun unrevert(id: String, dir: String) { call { unrevert(id, dir) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 8633382539..c59014686d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -367,6 +367,7 @@ class SessionUi( resize = { anchor, fn -> scroll.preserve(anchor, fn) }, revert = ::revert, cancelRevert = ::cancelRevert, + deleteQueued = { id -> controller.deleteQueuedMessage(id) }, banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } @@ -543,6 +544,8 @@ class SessionUi( is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) + is SessionModelEvent.QueueChanged -> Unit + is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0570d4bd36..ec3299f6b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -464,6 +464,19 @@ class SessionController( } } + fun deleteQueuedMessage(message: String) { + assertEdt() + val id = sid ?: return + capture("Conversation Queued Message Removed", sessionProps(id)) + cs.launch { + try { + sessions.deleteMessage(id, directory, message) + } catch (e: Exception) { + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e) + } + } + } + fun unrevert() { assertEdt() val id = sid ?: return @@ -1368,6 +1381,8 @@ class SessionController( idle() } + is ChatEventDto.SessionQueueChanged -> updateModel { model.setQueued(event.queued.toSet()) } + is ChatEventDto.SessionCompacted -> { capture("Context Condensed", sessionProps(event.sessionID)) model.markCompacted() @@ -1406,7 +1421,8 @@ class SessionController( is ChatEventDto.QuestionRejected, is ChatEventDto.SessionStatusChanged, is ChatEventDto.SessionUpdated, - is ChatEventDto.SessionIdle -> { + is ChatEventDto.SessionIdle, + is ChatEventDto.SessionQueueChanged -> { edt { if (disposed) return@edt updateModel { handleMetadata(event) } @@ -1428,6 +1444,7 @@ class SessionController( is ChatEventDto.SessionStatusChanged -> status(event.status) is ChatEventDto.SessionUpdated -> model.setSession(event.session) is ChatEventDto.SessionIdle -> idle() + is ChatEventDto.SessionQueueChanged -> model.setQueued(event.queued.toSet()) else -> Unit } } @@ -2312,6 +2329,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id is ChatEventDto.TodoUpdated -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index fa9412ff3e..14f7b8c027 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -74,6 +74,9 @@ class SessionModel { private var revert: SessionRevertDto? = null + var queued: Set = emptySet() + private set + var header: SessionHeaderSnapshot = emptyHeader() private set @@ -125,6 +128,9 @@ class SessionModel { return idx >= 0 && pos >= idx } + @RequiresEdt + fun isQueued(id: String): Boolean = id in queued + @RequiresEdt fun turn(id: String): Turn? = turnEntries[id] @@ -295,6 +301,13 @@ class SessionModel { fire(SessionModelEvent.RevertChanged(revert)) } + @RequiresEdt + fun setQueued(ids: Set) { + if (queued == ids) return + queued = ids + fire(SessionModelEvent.QueueChanged(ids)) + } + @RequiresEdt fun setDiff(diff: List) { this.diff = diff @@ -329,6 +342,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() @@ -363,6 +377,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index bc553cfe58..d3af00f638 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -60,6 +60,9 @@ sealed class SessionModelEvent { data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() { override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}" } + data class QueueChanged(val queued: Set) : SessionModelEvent() { + override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]" + } data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() { override fun toString() = "HeaderUpdated visible=${header.visible}" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index a0dc4d7dfd..3396a1c1ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -60,6 +60,7 @@ class SessionMessageListPanel( private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, private val cancelRevert: (() -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, private val banner: RevertBanner? = null, ) : SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, @@ -148,6 +149,12 @@ class SessionMessageListPanel( refresh() } + is SessionModelEvent.QueueChanged -> { + syncQueued() + syncSettled() + refresh() + } + // Message events: structural changes are handled via turn events above. is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -216,7 +223,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -224,6 +231,7 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() add(tv) syncSettled() @@ -251,6 +259,7 @@ class SessionMessageListPanel( register(id, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() syncSettled() @@ -279,7 +288,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -287,11 +296,13 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) add(tv) } syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverted() syncReverting(model.state) banner?.update() @@ -321,6 +332,7 @@ class SessionMessageListPanel( removeAll() syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverting(model.state) banner?.update() anchorFooter() @@ -384,10 +396,18 @@ class SessionMessageListPanel( } private fun syncSettled(state: SessionState = model.state) { - val active = if (state.isBusy()) turnViews.values.lastOrNull() else null + val active = if (state.isBusy()) turnViews.values.lastOrNull { !model.isQueued(it.id) } else null for (view in turnViews.values) view.setSettled(view !== active) } + private fun syncQueued() { + for (view in turnViews.values) syncQueued(view) + } + + private fun syncQueued(view: TurnView) { + view.setQueued(model.isQueued(view.id)) { id -> deleteQueued?.invoke(id) } + } + /** * Re-insert [question], [permission], [login], and [progress] as the last children * so active views always render after all turn views, and progress is last. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index d3a47c3260..96d2a8e768 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -207,6 +207,7 @@ class SessionHeaderPanel( is SessionModelEvent.TodosUpdated, is SessionModelEvent.SessionUpdated, is SessionModelEvent.RevertChanged, + is SessionModelEvent.QueueChanged, is SessionModelEvent.Compacted, is SessionModelEvent.HistoryLoaded, is SessionModelEvent.Cleared, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ec5f7e7c1a..9a4fd04d14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -212,7 +212,7 @@ class PromptPanel( isFocusPainted = false addActionListener { syncTooltip() - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID + val id = if (busy && !hasDraft()) StopSessionAction.ID else SendPromptAction.ID val action = ActionManager.getInstance().getAction(id) ?: return@addActionListener val ctx = DataManager.getInstance().getDataContext(button) @@ -258,7 +258,7 @@ class PromptPanel( private var request = 0L override val isSendEnabled: Boolean - get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) + get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) override val isStopEnabled: Boolean get() = busy @@ -273,6 +273,7 @@ class PromptPanel( syncEditorHeight() triggerCompletion(e) syncHighlights() + syncButton() onChange() } }) @@ -418,7 +419,7 @@ class PromptPanel( fun setBusy(value: Boolean) { busy = value if (value) invalidateEnhancement() else syncEnhance() - button.icon = if (value) STOP_ICON else SEND_ICON + syncButton() syncTooltip() } @@ -628,6 +629,11 @@ class PromptPanel( } } + @RequiresEdt + private fun syncButton() { + button.icon = if (busy && !hasDraft()) STOP_ICON else SEND_ICON + } + @RequiresEdt private fun submit(src: String) { if (!isSendEnabled) return @@ -885,19 +891,20 @@ class PromptPanel( } private fun tooltip(): String { - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID - val text = if (busy) { + val stop = busy && !hasDraft() + val id = if (stop) StopSessionAction.ID else SendPromptAction.ID + val text = if (stop) { KiloBundle.message("prompt.button.stop") } else { KiloBundle.message("prompt.button.send") } val tip = KeymapUtil.createTooltipText(text, id) - if (busy) return tip - val stop = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) - if (stop.isEmpty()) return tip + if (stop) return tip + val shortcut = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) + if (shortcut.isEmpty()) return tip return XmlStringUtil.wrapInHtml( XmlStringUtil.escapeString(tip) + "
" + - XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop)) + XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut)) ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt index 4ffeee1446..1df7642dc3 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt @@ -33,6 +33,7 @@ internal class MessageToolbar( buttons.forEach { next(it) } next(button) } + private var custom: JComponent? = null init { isOpaque = false @@ -41,10 +42,12 @@ internal class MessageToolbar( @RequiresEdt fun sync(value: Boolean) { - if (isVisible == value && button.isEnabled == value) return + val controls = customButtons() + if (isVisible == value && button.isEnabled == value && controls.all { it.isEnabled == value }) return isVisible = value button.isEnabled = value buttons.forEach { it.isEnabled = value } + controls.forEach { it.isEnabled = value } revalidate() repaint() } @@ -60,6 +63,16 @@ internal class MessageToolbar( @RequiresEdt fun copyButton() = button + @RequiresEdt + fun setCustom(node: JComponent?) { + if (custom === node) return + remove(custom ?: row) + custom = node + add(node ?: row) + revalidate() + repaint() + } + fun placeholder(): JComponent = object : JPanel() { init { isOpaque = false @@ -78,4 +91,6 @@ internal class MessageToolbar( copy.dismiss() super.removeNotify() } + + private fun customButtons() = custom?.components?.filterIsInstance().orEmpty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index aed735293b..5ccc77850f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -19,13 +19,21 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Point import java.awt.Graphics @@ -397,6 +405,12 @@ class MessageView( wrap?.setReverting(active, text, onCancel) } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (role != SessionUiStyle.View.Message.USER_ROLE) return + wrap?.setQueued(active, onDelete) + } + private val promptToolbar: MessageToolbar? get() = wrap?.bar @@ -494,21 +508,26 @@ class MessageView( private inner class PromptWrap( private val box: JPanel, ) : JPanel(BorderLayout()), SessionCopyTarget { + private val footer = JPanel(BorderLayout()).also { it.isOpaque = false } val bar = MessageToolbar( { prompt?.copyMarkdown(trim = false) }, revert?.let { fn -> { fn(msg.info.id) } }, ) private val placeholder = bar.placeholder() - private var progress: RevertProgress? = null private var reverting = false + private var progress: RevertProgress? = null + private var queuedRow: JPanel? = null + private var queued = false override val copyAnchor: JComponent get() = placeholder - override val copyToolbar: JComponent? get() = if (reverting) null else bar + override val copyToolbar: JComponent? get() = if (reverting || queued) null else bar init { isOpaque = false add(box, BorderLayout.CENTER) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + footer.border = JBUI.Borders.emptyTop(UiStyle.Gap.xs()) + footer.add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.CENTER) + add(footer, BorderLayout.SOUTH) } override fun copyText(): String? = prompt?.copyMarkdown(trim = false) @@ -523,19 +542,54 @@ class MessageView( node.setText(text) if (reverting) return reverting = true - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(node.align(HAlign.LEFT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(node.align(HAlign.LEFT, VAlign.TOP)) revalidate() repaint() return } if (!reverting) return reverting = false - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) revalidate() repaint() } + + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (active) { + val node = queuedRow ?: queue(onDelete).also { queuedRow = it } + if (queued) return + queued = true + swapFooter(node.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + return + } + if (!queued) return + queued = false + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + } + + private fun swapFooter(node: JComponent) { + footer.removeAll() + footer.add(node, BorderLayout.CENTER) + } + + private fun queue(onDelete: () -> Unit) = Stack.horizontal(UiStyle.Gap.sm()).also { row -> + row.isOpaque = false + row.next(JBLabel(KiloBundle.message("session.queued")).apply { + foreground = UIUtil.getContextHelpForeground() + }) + row.next(toolbarButton( + ToolbarButtonAction( + AllIcons.Actions.Close, + KiloBundle.message("session.queued.remove"), + onDelete, + ), + )) + } } private fun assistantBorder() = JBUI.Borders.empty() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index b4d8684004..cc3cc7d74c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -36,6 +36,7 @@ class TurnView( private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() @@ -66,11 +67,18 @@ class TurnView( val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert) messages[msg.info.id] = view add(view) + if (msg.info.id == id && deleteQueued != null) view.setQueued(false) { deleteQueued.invoke(id) } syncCopyToolbars() revalidate() return view } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: (String) -> Unit) { + val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return + anchor.setQueued(active) { onDelete(id) } + } + /** Remove the [MessageView] for [msgId] if present. */ fun removeMessage(msgId: String) { removeMessageChanged(msgId) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 2557da487f..6f26bb566d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -31,6 +31,8 @@ session.copy.hover=Copy session.copy.prompt=Copy prompt session.copy.response=Copy response session.copy.copied=Copied +session.queued=Queued +session.queued.remove=Remove queued message session.drop.files.title=Drop files here session.drop.files.subtitle=to add them to the prompt session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index b84cdb5da6..05dd2e9a6e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -102,6 +102,33 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertFalse(message.properties.containsValue("git-changes")) } + fun `test session queue changed updates queued set`() { + val (c, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionQueueChanged("ses_test", listOf("u2"))) + + assertEquals(setOf("u2"), c.model.queued) + assertModelEvents( + """ + QueueChanged [u2] + """, + modelEvents, + ) + + emit(ChatEventDto.SessionQueueChanged("ses_test", emptyList())) + + assertEquals(emptySet(), c.model.queued) + } + + fun `test delete queued message delegates to RPC`() { + val (c, _, _) = prompted() + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + } + fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 70a6dc52bb..745062e15f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1056,7 +1056,7 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\"")) } - fun `test busy disables send button`() { + fun `test busy allows sending draft`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setReady(true) ApplicationManager.getApplication().invokeAndWait { panel.setText("hello") } @@ -1065,8 +1065,9 @@ class PromptPanelTest : BasePlatformTestCase() { panel.setBusy(true) - assertFalse(panel.isSendEnabled) + assertTrue(panel.isSendEnabled) assertTrue(panel.isStopEnabled) + assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) } fun `test auto approve button toggles and updates tooltip`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index d5e72cfc08..b27219697a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -28,6 +28,7 @@ import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -129,6 +130,32 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { ) } + fun `test queued turn shows badge and remove action`() { + var deleted: String? = null + Disposer.dispose(parent) + parent = Disposer.newDisposable("test-queued") + model = SessionModel() + panel = SessionMessageListPanel(model, parent, openFile = openFile, deleteQueued = { deleted = it }) + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "first")) + model.upsertMessage(msg("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", text = "second")) + + model.setQueued(setOf("u2")) + + val u1 = panel.findMessage("u1")!! + val u2 = panel.findMessage("u2")!! + assertFalse(components(u1).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + assertTrue(components(u2).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + + val remove = components(u2).filterIsInstance().single() + assertEquals(KiloBundle.message("session.queued.remove"), remove.toolTipText) + assertEquals(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR), remove.cursor) + remove.doClick() + + assertEquals("u2", deleted) + } + // ------ TurnAdded ------ fun `test user message creates turn and is findable by message id`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index cb14e09f07..65f282db4e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val aborts = mutableListOf>() val compacts = mutableListOf>() val reverts = mutableListOf() + val messageDeletes = mutableListOf() + var messageDeleteResult = true val unreverts = mutableListOf>() val configs = mutableListOf>() val permissionReplies = mutableListOf>() @@ -117,6 +119,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?) data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto) data class RevertCall(val id: String, val directory: String, val message: String, val part: String?) + data class MessageDeleteCall(val id: String, val directory: String, val message: String) // --- Implementation --- @@ -229,6 +232,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi { reverts.add(RevertCall(id, directory, messageID, partID)) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean { + assertNotEdt("deleteMessage") + messageDeletes.add(MessageDeleteCall(id, directory, messageID)) + return messageDeleteResult + } + override suspend fun unrevert(id: String, directory: String) { assertNotEdt("unrevert") unrevertGate?.await() diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 8c092f6821..fee84e7b2a 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -8,7 +8,7 @@ "test": "./gradlew test", "test:ci": "bun script/test-ci.ts" }, - "version": "7.4.15", + "version": "7.4.16", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 4f8e06f169..588c0c9d0e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -33,6 +33,7 @@ object ChatLogSummary { is ChatEventDto.SessionStatusChanged -> event.sessionID is ChatEventDto.SessionUpdated -> event.sessionID is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionQueueChanged -> event.sessionID is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID @@ -211,6 +212,12 @@ object ChatLogSummary { "evt=session.idle", ) + is ChatEventDto.SessionQueueChanged -> join( + sid(event.sessionID), + "evt=session.queue.changed", + "queued=${event.queued.size}", + ) + is ChatEventDto.SessionCompacted -> join( sid(event.sessionID), "evt=session.compacted", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 6b6cfa4f97..0fab2e8696 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Revert a session to a prior user message or part. */ suspend fun revert(id: String, directory: String, messageID: String, partID: String?) + /** Delete a single message (used to remove a queued prompt). */ + suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean + /** Redo all reverted changes for a session. */ suspend fun unrevert(id: String, directory: String) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index dbb053f8fd..edbbed7b02 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -254,6 +254,13 @@ sealed class ChatEventDto { val sessionID: String, ) : ChatEventDto() + @Serializable + @SerialName("session.queue.changed") + data class SessionQueueChanged( + val sessionID: String, + val queued: List = emptyList(), + ) : ChatEventDto() + @Serializable @SerialName("session.compacted") data class SessionCompacted( From b8d83fb537040afd6632a6d893acc412395832e4 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:36:51 +0000 Subject: [PATCH 06/35] fix(cli): support adaptive thinking for opus 5 Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/adaptive-opus-five.md | 5 +++ packages/opencode/src/provider/transform.ts | 6 +-- .../test/kilocode/transform-opus-4.7.test.ts | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 .changeset/adaptive-opus-five.md diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md new file mode 100644 index 0000000000..0314655ca9 --- /dev/null +++ b/.changeset/adaptive-opus-five.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Support adaptive thinking levels for Claude Opus 5. diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c09f204859..5b293fc5f0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -616,10 +616,10 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } -// kilocode_change start - fable and sonnet-5 models are adaptive thinking models like opus-4.7/4.8 +// kilocode_change start - Claude 5 models are adaptive thinking models like opus-4.7/4.8 function anthropicClaude5(apiId: string) { const id = apiId.toLowerCase() - return id.includes("fable") || /sonnet[.-]5/.test(id) + return id.includes("fable") || /(?:opus|sonnet)[.-]5/.test(id) } // kilocode_change end @@ -640,7 +640,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's fable/sonnet-5 aliases + return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's Claude 5 aliases } function googleThinkingLevelEfforts(apiId: string) { diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts index 7626bfd920..3f6f1fe512 100644 --- a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts +++ b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts @@ -207,6 +207,50 @@ describe("ProviderTransform.variants - Claude Opus 4.7 / 4.8", () => { }) }) + test("opus-5 returns adaptive thinking variants including xhigh (native anthropic)", () => { + const model = mockModel({ + api: { + id: "claude-opus-5", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }) + }) + + test("opus-5 returns adaptive thinking variants via @ai-sdk/gateway", () => { + const model = mockModel({ + id: "anthropic/claude-opus-5", + api: { + id: "anthropic/claude-opus-5", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) + + test("opus-5 on bedrock returns adaptive reasoningConfig with xhigh", () => { + const model = mockModel({ + api: { + id: "anthropic.claude-opus-5", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, + }) + }) + test("sonnet-4.6 keeps original adaptive efforts without xhigh", () => { const model = mockModel({ api: { From a69a7cc5c4064333ba3386b325d490edb946f949 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:03:01 +0000 Subject: [PATCH 07/35] fix(cli): cover future opus adaptive versions Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 10 ++++++---- .../test/kilocode/transform-opus-4.7.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 5b293fc5f0..752e618e91 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -616,15 +616,17 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } -// kilocode_change start - Claude 5 models are adaptive thinking models like opus-4.7/4.8 +// kilocode_change start - Claude 5 aliases and Opus 5+ are adaptive thinking models like opus-4.7/4.8 function anthropicClaude5(apiId: string) { const id = apiId.toLowerCase() - return id.includes("fable") || /(?:opus|sonnet)[.-]5/.test(id) + if (id.includes("fable") || /sonnet[.-]5/.test(id)) return true + const opus = /opus[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-opus(?:[.@-]|$)/.exec(id) + return Number(opus?.[1] ?? opus?.[2]) >= 5 } // kilocode_change end function anthropicAdaptiveEfforts(apiId: string): string[] | null { - // kilocode_change start - treat opus-4.8 and fable like opus-4.7 + // kilocode_change start - include Claude 5 aliases and future Opus versions if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } @@ -977,7 +979,7 @@ export function variants(model: Provider.Model): Record { }) }) + test.each(["claude-opus-5.3", "claude-opus-6", "claude-6-opus", "claude-opus-10"])( + "%s is treated as an adaptive thinking model", + (id) => { + const model = mockModel({ + api: { + id, + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }) + }, + ) + test("sonnet-4.6 keeps original adaptive efforts without xhigh", () => { const model = mockModel({ api: { From 8aeff4856ff0fe1553918a27c02ab0601707cef8 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sun, 26 Jul 2026 20:50:52 +0000 Subject: [PATCH 08/35] fix(cli): cover future sonnet adaptive versions Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 12 ++++++------ .../test/kilocode/transform-opus-4.7.test.ts | 11 ++++++++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 752e618e91..a8d5d25f29 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -616,17 +616,17 @@ function anthropicOpus47OrLater(apiId: string) { return major > 4 || (major === 4 && minor >= 7) } -// kilocode_change start - Claude 5 aliases and Opus 5+ are adaptive thinking models like opus-4.7/4.8 +// kilocode_change start - Claude 5+ models are adaptive thinking models like opus-4.7/4.8 function anthropicClaude5(apiId: string) { const id = apiId.toLowerCase() - if (id.includes("fable") || /sonnet[.-]5/.test(id)) return true - const opus = /opus[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-opus(?:[.@-]|$)/.exec(id) - return Number(opus?.[1] ?? opus?.[2]) >= 5 + if (id.includes("fable")) return true + const version = /(?:opus|sonnet)[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-(?:opus|sonnet)(?:[.@-]|$)/.exec(id) + return Number(version?.[1] ?? version?.[2]) >= 5 } // kilocode_change end function anthropicAdaptiveEfforts(apiId: string): string[] | null { - // kilocode_change start - include Claude 5 aliases and future Opus versions + // kilocode_change start - include Claude 5+ models if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } @@ -979,7 +979,7 @@ export function variants(model: Provider.Model): Record { }) }) - test.each(["claude-opus-5.3", "claude-opus-6", "claude-6-opus", "claude-opus-10"])( + test.each([ + "claude-opus-5.3", + "claude-opus-6", + "claude-6-opus", + "claude-opus-10", + "claude-sonnet-5.3", + "claude-sonnet-6", + "claude-6-sonnet", + "claude-sonnet-10", + ])( "%s is treated as an adaptive thinking model", (id) => { const model = mockModel({ From 737bc7b3927d31afc8d5d37815d30249e6b49461 Mon Sep 17 00:00:00 2001 From: chrarnoldus <12196001+chrarnoldus@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:00:36 +0000 Subject: [PATCH 09/35] docs: include sonnet in adaptive thinking note Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .changeset/adaptive-opus-five.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/adaptive-opus-five.md b/.changeset/adaptive-opus-five.md index 0314655ca9..9d8cf6c1b3 100644 --- a/.changeset/adaptive-opus-five.md +++ b/.changeset/adaptive-opus-five.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Support adaptive thinking levels for Claude Opus 5. +Support adaptive thinking levels for Claude Opus and Sonnet 5 and later. From b2735bfbc9df170274a12ec4786106dacb61090f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 27 Jul 2026 11:12:42 +0200 Subject: [PATCH 10/35] fix(cli): flush the session ingest tail on shutdown (#12545) * fix(cli): drain session ingest queue on shutdown and flush terminal batches promptly * fix(cli): drain the session ingest queue on process shutdown * fix(cli): pin drain bound expiry, add changeset, conform to naming rule * fix(cli): keep kilo-sessions out of the CLI startup import graph * fix(cli): never let the ingest drain task reject the shutdown sequence * test(cli): pin drain-before-dispose ordering on the KiloCli shutdown path * fix(cli): make the guarded ingest drain non-rejecting and correct the lazy-import rationale * test(cli): cover the retryable-status drain path under shutdown * test(cli): decouple cli-shutdown drain assertions from declaration order --- .changeset/ingest-shutdown-flush.md | 5 + packages/opencode/src/cli/cmd/serve.ts | 2 + packages/opencode/src/cli/cmd/tui.ts | 3 + .../opencode/src/cli/tui/worker-shutdown.ts | 14 + packages/opencode/src/cli/tui/worker.ts | 14 +- .../src/kilo-sessions/ingest-drain.ts | 18 + .../src/kilo-sessions/ingest-queue.ts | 144 ++++++- .../src/kilo-sessions/kilo-sessions.ts | 13 + packages/opencode/src/kilocode/cli/setup.ts | 18 + .../test/kilocode/cli-shutdown.test.ts | 62 ++- .../kilocode/sessions/ingest-drain.test.ts | 61 +++ .../kilocode/sessions/ingest-queue.test.ts | 375 ++++++++++++++++++ .../kilocode/sessions/worker-shutdown.test.ts | 55 +++ 13 files changed, 761 insertions(+), 23 deletions(-) create mode 100644 .changeset/ingest-shutdown-flush.md create mode 100644 packages/opencode/src/cli/tui/worker-shutdown.ts create mode 100644 packages/opencode/src/kilo-sessions/ingest-drain.ts create mode 100644 packages/opencode/test/kilocode/sessions/ingest-drain.test.ts create mode 100644 packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts diff --git a/.changeset/ingest-shutdown-flush.md b/.changeset/ingest-shutdown-flush.md new file mode 100644 index 0000000000..94ff86a5b1 --- /dev/null +++ b/.changeset/ingest-shutdown-flush.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes. diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 6eb7277b06..cbb65e4e97 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -4,6 +4,7 @@ import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change export const ServeCommand = effectCmd({ command: "serve", @@ -38,6 +39,7 @@ export const ServeCommand = effectCmd({ const shutdown = async () => { stopWatchdog() try { + await KiloSessions.drainIngestForShutdown() // kilocode_change await InstanceRuntime.disposeAllInstances() await server.stop(true) } finally { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index ce9fa7e351..0bd54ed584 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -283,6 +283,9 @@ export const TuiThreadCommand = cmd({ } process.once("SIGHUP", () => shutdownAndExit({ reason: "signal", signal: "SIGHUP", code: 129 })) process.once("SIGTERM", () => shutdownAndExit({ reason: "signal", signal: "SIGTERM", code: 143 })) + // kilocode_change - external kill -INT takes the same graceful path as SIGHUP/SIGTERM. + // Interactive Ctrl-C in the TUI is a raw-mode keypress, not a signal. + process.once("SIGINT", () => shutdownAndExit({ reason: "signal", signal: "SIGINT", code: 130 })) // In some terminal/tab-close paths the parent shell is terminated without // forwarding a signal to this process, leaving the TUI orphaned. Detect // parent PID re-parenting and exit explicitly. diff --git a/packages/opencode/src/cli/tui/worker-shutdown.ts b/packages/opencode/src/cli/tui/worker-shutdown.ts new file mode 100644 index 0000000000..e084c28f7c --- /dev/null +++ b/packages/opencode/src/cli/tui/worker-shutdown.ts @@ -0,0 +1,14 @@ +// kilocode_change - new file +// Pure shutdown sequence for the embedded TUI worker. Extracted so unit tests can +// assert drain → dispose → stopServer ordering without loading worker.ts side effects. +export function createWorkerShutdown(input: { + drain: () => Promise + dispose: () => Promise + stopServer: () => Promise +}) { + return async () => { + await input.drain() + await input.dispose() + await input.stopServer() + } +} diff --git a/packages/opencode/src/cli/tui/worker.ts b/packages/opencode/src/cli/tui/worker.ts index 4543ab08c4..5232ed0b87 100644 --- a/packages/opencode/src/cli/tui/worker.ts +++ b/packages/opencode/src/cli/tui/worker.ts @@ -13,6 +13,8 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy import { KiloLog } from "@/kilocode/log" // kilocode_change import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change +import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal @@ -25,6 +27,15 @@ GlobalBus.on("event", (event) => { let server: Awaited> | undefined const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change +// kilocode_change start - drain ingest before dispose so GlobalBus/remote stay live +const runShutdown = createWorkerShutdown({ + drain: () => KiloSessions.drainIngestForShutdown(), + dispose: () => InstanceRuntime.disposeAllInstances(), + stopServer: async () => { + if (server) await server.stop(true) + }, +}) +// kilocode_change end export const rpc = { // kilocode_change start - worker lifecycle hooks for remote exit @@ -78,8 +89,7 @@ export const rpc = { }, async shutdown() { remoteExit.shutdown() // kilocode_change - await InstanceRuntime.disposeAllInstances() - if (server) await server.stop(true) + await runShutdown() // kilocode_change - drain → dispose → stopServer // kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and // exit naturally. Without this, the active onmessage handle keeps the // worker alive even after all async work is done. diff --git a/packages/opencode/src/kilo-sessions/ingest-drain.ts b/packages/opencode/src/kilo-sessions/ingest-drain.ts new file mode 100644 index 0000000000..09024f371b --- /dev/null +++ b/packages/opencode/src/kilo-sessions/ingest-drain.ts @@ -0,0 +1,18 @@ +// Once-per-process guard around the session ingest shutdown drain. +// Overlapping shutdown paths (worker RPC, KiloShutdown, serve signals) must not double-POST. +// The guarded call never rejects: a drain failure must not block the remaining shutdown +// sequence (disposeAllInstances / server.stop). Failures are logged once via the optional +// onError callback; later callers share the same resolved promise (no retry). +export namespace IngestDrain { + export function create(run: () => Promise, onError?: (err: unknown) => void) { + let done: Promise | undefined + return () => { + if (!done) { + done = run().catch((err) => { + onError?.(err) + }) + } + return done + } + } +} diff --git a/packages/opencode/src/kilo-sessions/ingest-queue.ts b/packages/opencode/src/kilo-sessions/ingest-queue.ts index 83f6b9583e..ed9eb0187c 100644 --- a/packages/opencode/src/kilo-sessions/ingest-queue.ts +++ b/packages/opencode/src/kilo-sessions/ingest-queue.ts @@ -83,7 +83,8 @@ export namespace IngestQueue { // To avoid spamming the server, we coalesce updates and flush at most once per ~1s per session. // // `due` is the earliest time we should flush; it is also used to respect backoff when retries are - // active. A later `due` always wins over an earlier one. + // active. A later `due` always wins over an earlier one for non-terminal batches. Terminal batches + // (`session_close`) may pull the flush earlier. const queue = new Map }>() // Per-session retry state. @@ -94,6 +95,18 @@ export namespace IngestQueue { // - Store `until` so sync() can avoid scheduling a flush before backoff expires const retry = new Map() + // In-flight flush promises. flush() deletes the queue entry before I/O, so an empty queue is not + // quiescence — drain must join these too. + const inflight = new Set>() + + // Last successfully resolved client and per-session share. Drain falls back to these when + // getClient/getShare fail during teardown (e.g. authValid HTTP check). + let cached: Client | undefined + const shares = new Map() + + // Shutdown mode: one attempt per item, no re-enqueue, use cached client/share on resolution failure. + let shutting = false + const now = options.now ?? (() => Date.now()) const set = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)) const clear = options.clearTimeout ?? ((timer) => clearTimeout(timer)) @@ -154,12 +167,12 @@ export namespace IngestQueue { return models.length > 0 ? `model:${models}` : ulid() } - function schedule(sessionId: string, due: number, data: Map) { + function schedule(sessionId: string, due: number, data: Map, terminal = false) { const existing = queue.get(sessionId) if (existing) { - // Don't reschedule if an earlier flush is already planned. - // We only move the flush later (e.g., to respect backoff). - if (existing.due >= due) return + // Non-terminal: only move the flush later (e.g., to respect backoff). + // Terminal (`session_close`): may pull the flush earlier so the tail is not left behind. + if (!terminal && existing.due >= due) return clear(existing.timeout) } @@ -170,7 +183,7 @@ export namespace IngestQueue { queue.set(sessionId, { timeout, due, data }) } - function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number) { + function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number, terminal = false) { const existing = queue.get(sessionId) if (existing) { for (const item of items) { @@ -180,7 +193,7 @@ export namespace IngestQueue { if (mode === "fill" && existing.data.has(k)) continue existing.data.set(k, item) } - schedule(sessionId, due, existing.data) + schedule(sessionId, due, existing.data, terminal) return } @@ -189,7 +202,12 @@ export namespace IngestQueue { data.set(key(item), item) } - schedule(sessionId, due, data) + schedule(sessionId, due, data, terminal) + } + + function requeue(sessionId: string, items: Data[], delay: number) { + if (shutting) return + enqueue(sessionId, items, "fill", now() + delay) } async function flush(sessionId: string) { @@ -204,13 +222,49 @@ export namespace IngestQueue { queue.delete(sessionId) const items = Array.from(queued.data.values()) - + const done = run(sessionId, items) + inflight.add(done) try { - const share = await options.getShare(sessionId).catch(() => undefined) - if (!share) return + await done + } finally { + inflight.delete(done) + } + } - const client = await options.getClient() - if (!client) return + async function resolveShare(sessionId: string) { + const fresh = await options.getShare(sessionId).catch(() => undefined) + if (fresh) shares.set(sessionId, fresh) + return fresh ?? (shutting ? shares.get(sessionId) : undefined) + } + + async function resolveClient() { + // Preserve normal-path throw → outer catch logging; only swallow during shutdown so the + // cached client can be used. + const fresh = await options.getClient().catch((error) => { + if (!shutting) throw error + return undefined + }) + if (fresh) cached = fresh + return fresh ?? (shutting ? cached : undefined) + } + + async function run(sessionId: string, items: Data[]) { + try { + const share = await resolveShare(sessionId) + if (!share) { + if (shutting) { + options.log.error("ingest drain skipped", { sessionId, reason: "no share" }) + } + return + } + + const client = await resolveClient() + if (!client) { + if (shutting) { + options.log.error("ingest drain skipped", { sessionId, reason: "no client" }) + } + return + } if (options.log.info) { const types = items.map((d) => d.type).join(",") @@ -233,6 +287,12 @@ export namespace IngestQueue { if (!response) { // Network failures are assumed transient; retry with backoff and a small budget. + // Shutdown: one attempt only — log and drop so the process can exit. + if (shutting) { + options.log.error("share sync failed", { sessionId, error: "network", shutdown: true }) + return + } + const count = (retry.get(sessionId)?.count ?? 0) + 1 if (count > 6) { options.log.error("share sync failed", { sessionId, error: "retry budget exceeded" }) @@ -243,7 +303,7 @@ export namespace IngestQueue { const delay = backoff(count) retry.set(sessionId, { count, until: now() + delay }) options.log.error("share sync failed", { sessionId, error: "network", attempt: count, retryInMs: delay }) - enqueue(sessionId, items, "fill", now() + delay) + requeue(sessionId, items, delay) return } @@ -276,6 +336,16 @@ export namespace IngestQueue { return } + if (shutting) { + options.log.error("share sync failed", { + sessionId, + status: response.status, + statusText: response.statusText, + shutdown: true, + }) + return + } + const current = retry.get(sessionId) const count = (current?.count ?? 0) + 1 if (count > 6) { @@ -293,7 +363,7 @@ export namespace IngestQueue { attempt: count, retryInMs: delay, }) - enqueue(sessionId, items, "fill", now() + delay) + requeue(sessionId, items, delay) } catch (error) { options.log.error("share sync failed", { sessionId, error }) } @@ -305,6 +375,7 @@ export namespace IngestQueue { // - Otherwise, merge into the pending queue entry. // The next flush is scheduled ~1s after the first queued event (throttled), but never earlier // than the current backoff window (if retries are active). + // - A batch containing session_close is terminal: flush ASAP (respecting backoff only). const client = await options.getClient() if (!client) return @@ -313,15 +384,54 @@ export namespace IngestQueue { options.log.info("ingest sync", { sessionId, types }) } + const terminal = data.some((item) => item.type === "session_close") const until = retry.get(sessionId)?.until ?? 0 - const base = queue.get(sessionId)?.due ?? now() + 1000 + // Terminal batches do not inherit the open debounce window — only backoff. + const base = terminal ? now() : (queue.get(sessionId)?.due ?? now() + 1000) const due = Math.max(base, until) - enqueue(sessionId, data, "overwrite", due) + enqueue(sessionId, data, "overwrite", due, terminal) + } + + async function drain(bound = 3_000) { + // Shutdown drain: one bounded attempt per pending session, join in-flight flushes, no re-enqueue. + shutting = true + const deadline = now() + bound + + for (const sessionId of Array.from(queue.keys())) { + void flush(sessionId) + } + + while (queue.size > 0 || inflight.size > 0) { + for (const sessionId of Array.from(queue.keys())) { + void flush(sessionId) + } + + if (now() >= deadline) { + options.log.error("ingest drain timed out", { + queue: queue.size, + inflight: inflight.size, + bound, + }) + return + } + + if (inflight.size === 0) continue + + const pending = Array.from(inflight) + const left = Math.max(0, deadline - now()) + let timer: Timer | undefined + const timeout = new Promise((resolve) => { + timer = set(() => resolve(), left) + }) + await Promise.race([Promise.allSettled(pending), timeout]) + if (timer !== undefined) clear(timer) + } } return { sync, flush, + drain, } as const } } diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index debcc69b0a..75f2b541ad 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -13,6 +13,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Auth } from "@/auth" import { makeRuntime } from "@/effect/run-service" import { IngestQueue } from "@/kilo-sessions/ingest-queue" +import { IngestDrain } from "@/kilo-sessions/ingest-drain" import { clearInFlightCache, withInFlightCache } from "@/kilo-sessions/inflight-cache" import type * as SDK from "@kilocode/sdk/v2" import z from "zod" @@ -222,6 +223,18 @@ export namespace KiloSessions { }, }) + // Process-level once-guard: overlapping shutdown paths must not double-POST. + // Do not call from per-directory instance finalizers — wrong granularity. + // Never-reject: serve/worker await this unguarded before dispose/stop. + const drainIngest = IngestDrain.create( + () => ingest.drain(), + (err) => log.warn("ingest drain failed", { err }), + ) + + export async function drainIngestForShutdown() { + await drainIngest() + } + const remoteEnabled = process.env["KILO_REMOTE"] === "1" let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 2e41e1190f..561e5c5406 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -24,6 +24,24 @@ import { KiloLog } from "@/kilocode/log" const log = Log.create({ service: "kilocode.cli" }) +// Process-level ingest drain for non-TUI commands (`kilo run`, etc.). +// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order. +// Registered at setup load time (not inside shutdown()) so the task is always present. +// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load +// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits +// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import +// returns the same in-process module singleton, so the drained queue is the one that +// received events. Task try/catch covers dynamic-import failure outside the shared drain +// guard; the drain itself never rejects. +KiloShutdown.register(async () => { + try { + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + await KiloSessions.drainIngestForShutdown() + } catch (err) { + log.warn("ingest drain failed", { err }) + } +}) + // All Kilo-specific CLI customization lives here so the shared upstream entrypoint // (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers. // This keeps index.ts close to upstream and reduces merge conflicts on every sync. diff --git a/packages/opencode/test/kilocode/cli-shutdown.test.ts b/packages/opencode/test/kilocode/cli-shutdown.test.ts index e6fea808e8..5a4babfbe0 100644 --- a/packages/opencode/test/kilocode/cli-shutdown.test.ts +++ b/packages/opencode/test/kilocode/cli-shutdown.test.ts @@ -1,8 +1,11 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { KiloShutdown } from "../../src/kilocode/cli/shutdown" const calls: string[] = [] const timeouts: Array = [] let err: unknown +let drainErr: unknown +let drainCalls = 0 let exit: string | number | null | undefined mock.module("@opencode-ai/core/global", () => ({ @@ -67,6 +70,16 @@ mock.module("@/kilocode/session-export", () => ({ }, })) +mock.module("@/kilo-sessions/kilo-sessions", () => ({ + KiloSessions: { + async drainIngestForShutdown() { + drainCalls += 1 + calls.push("drain") + if (drainErr) throw drainErr + }, + }, +})) + mock.module("@/kilocode/help-command", () => ({ createHelpCommand: () => ({ command: "help", handler() {} }), })) @@ -94,11 +107,34 @@ for (const path of [ })) } +/** Same mock body as the kilo-sessions module mock used by setup.ts's drain task. */ +function registerDrain() { + KiloShutdown.register(async () => { + drainCalls += 1 + calls.push("drain") + if (drainErr) throw drainErr + }) +} + +/** + * Install a drain task for this test only. Clears any leftover registry entries first + * (setup.ts's one-time module-scope registration, or a prior test) so assertions do not + * depend on declaration order or on whether an earlier test already ran KiloShutdown.run(). + */ +async function installDrain() { + await KiloShutdown.run() + calls.length = 0 + drainCalls = 0 + registerDrain() +} + describe("KiloCli.shutdown", () => { beforeEach(() => { calls.length = 0 timeouts.length = 0 err = undefined + drainErr = undefined + drainCalls = 0 exit = process.exitCode process.exitCode = undefined }) @@ -107,26 +143,44 @@ describe("KiloCli.shutdown", () => { process.exitCode = exit }) - test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => { - err = "Timeout while shutting down PostHog. Some events may not have been sent." + // Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it. + // Only this test pins that one-time module-scope registration (and the drain-before-dispose + // ordering it enables). Later tests call installDrain() so they do not rely on order. + test("rejects drain without blocking dispose", async () => { + drainErr = new Error("ingest drain failed") process.exitCode = 0 const { KiloCli } = await import("../../src/kilocode/cli/setup") await expect(KiloCli.shutdown()).resolves.toBeUndefined() + expect(drainCalls).toBe(1) expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"]) + expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) + expect(process.exitCode).toBe(0) + }) + + test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => { + err = "Timeout while shutting down PostHog. Some events may not have been sent." + process.exitCode = 0 + const { KiloCli } = await import("../../src/kilocode/cli/setup") + await installDrain() + + await expect(KiloCli.shutdown()).resolves.toBeUndefined() + + expect(timeouts).toEqual([2000]) + expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) expect(process.exitCode).toBe(0) }) test("preserves failing command exit status", async () => { process.exitCode = 1 const { KiloCli } = await import("../../src/kilocode/cli/setup") + await installDrain() await KiloCli.shutdown() expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:1", "session", "telemetry", "dispose"]) + expect(calls).toEqual(["track:1", "session", "telemetry", "drain", "dispose"]) expect(process.exitCode).toBe(1) }) }) diff --git a/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts b/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts new file mode 100644 index 0000000000..d5a13b741c --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/ingest-drain.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { IngestDrain } from "../../../src/kilo-sessions/ingest-drain" + +describe("IngestDrain once-guard", () => { + test("overlapping invocations share a single underlying drain call", async () => { + let calls = 0 + let resolveDrain!: () => void + const gate = new Promise((resolve) => { + resolveDrain = resolve + }) + + const drain = IngestDrain.create(async () => { + calls += 1 + await gate + }) + + const first = drain() + const second = drain() + expect(calls).toBe(1) + + resolveDrain() + await Promise.all([first, second]) + expect(calls).toBe(1) + + await drain() + expect(calls).toBe(1) + }) + + test("sequential calls after completion still run only once", async () => { + let calls = 0 + const drain = IngestDrain.create(async () => { + calls += 1 + }) + + await drain() + await drain() + await drain() + expect(calls).toBe(1) + }) + + test("underlying run() rejection resolves, logs once, and does not retry", async () => { + let calls = 0 + const errors: unknown[] = [] + const drain = IngestDrain.create( + async () => { + calls += 1 + throw new Error("boom") + }, + (err) => { + errors.push(err) + }, + ) + + await expect(drain()).resolves.toBeUndefined() + await expect(drain()).resolves.toBeUndefined() + expect(calls).toBe(1) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(Error) + expect((errors[0] as Error).message).toBe("boom") + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts index 08cce19cd0..f2ffb1e192 100644 --- a/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts +++ b/packages/opencode/test/kilocode/sessions/ingest-queue.test.ts @@ -467,4 +467,379 @@ describe("share ingest queue", () => { expect(payload.data.length).toBe(1) expect(payload.data[0].data.message).toBe("second") }) + + test("drain flushes every pending session and does not re-enqueue on failure", async () => { + const sent: { sessionId: string; body: unknown }[] = [] + const sched = scheduler(() => clock.now) + let fail = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async (sessionId) => ({ ingestPath: `/ingest/${sessionId}` }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (input, init) => { + const url = String(input) + const sessionId = url.includes("/s-a") ? "s-a" : "s-b" + sent.push({ sessionId, body: JSON.parse((init?.body as string) ?? "{}") }) + if (fail) throw new Error("network") + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-a", [{ type: "session", data: { id: "s-a", v: 1 } as any }]) + await q.sync("s-b", [{ type: "session", data: { id: "s-b", v: 1 } as any }]) + expect(sched.size()).toBe(2) + + await q.drain() + await Bun.sleep(0) + + expect(sent.length).toBe(2) + expect(sent.map((s) => s.sessionId).sort()).toEqual(["s-a", "s-b"]) + expect(sched.size()).toBe(0) + + // Failure path: drain POSTs once and does not re-enqueue for retry. + fail = true + clock.now = 5000 + await q.sync("s-c", [{ type: "session", data: { id: "s-c", v: 1 } as any }]) + await q.sync("s-d", [{ type: "session", data: { id: "s-d", v: 1 } as any }]) + expect(sched.size()).toBe(2) + + const before = sent.length + await q.drain() + await Bun.sleep(0) + + expect(sent.length).toBe(before + 2) + expect(sched.size()).toBe(0) + }) + + test("drain does not re-enqueue on retryable HTTP status under shutdown", async () => { + const errors: Record[] = [] + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { + error: (_message, data) => { + errors.push(data) + }, + }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => new Response("", { status: 429 }), + }), + }) + + await q.sync("s-429", [{ type: "session", data: { id: "s-429", v: 1 } as any }]) + expect(sched.size()).toBe(1) + + await q.drain() + await Bun.sleep(0) + + // Shutdown path logs the retryable status and drops the item (no re-enqueue). + expect(errors.some((e) => e.status === 429 && e.shutdown === true)).toBe(true) + expect(sched.size()).toBe(0) + }) + + test("drain POSTs using cached client/share when resolution fails at teardown", async () => { + const urls: string[] = [] + const sched = scheduler(() => clock.now) + let live = true + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => { + if (!live) return undefined + return { ingestPath: "/ingest" } + }, + getClient: async () => { + if (!live) return undefined + return { + url: "https://ingest.test", + fetch: async (input) => { + urls.push(String(input)) + return new Response("{}", { status: 200 }) + }, + } + }, + }) + + // Prime cache with a successful flush. + await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 1 } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(urls).toEqual(["https://ingest.test/ingest?v=2"]) + + // Queue a new item, then break resolution so drain must use the cache. + clock.now = 2000 + await q.sync("s-cache", [{ type: "session", data: { id: "s-cache", v: 2 } as any }]) + live = false + + await q.drain() + await Bun.sleep(0) + + expect(urls.length).toBe(2) + expect(urls[1]).toBe("https://ingest.test/ingest?v=2") + expect(sched.size()).toBe(0) + }) + + test("drain waits for in-flight flush when queue map is empty", async () => { + const sched = scheduler(() => clock.now) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let started = false + let finished = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + await gate + finished = true + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-inflight", [{ type: "session", data: { id: "s-inflight" } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(started).toBe(true) + expect(finished).toBe(false) + expect(sched.size()).toBe(0) + + const drained = q.drain() + let drainDone = false + void drained.then(() => { + drainDone = true + }) + + await Bun.sleep(0) + expect(drainDone).toBe(false) + expect(finished).toBe(false) + + release() + await drained + await Bun.sleep(0) + + expect(finished).toBe(true) + expect(drainDone).toBe(true) + }) + + test("drain suppresses re-enqueue when joined in-flight flush fails retryably", async () => { + const sched = scheduler(() => clock.now) + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let started = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + await gate + throw new Error("network") + }, + }), + }) + + await q.sync("s-join-fail", [{ type: "session", data: { id: "s-join-fail" } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + expect(started).toBe(true) + expect(sched.size()).toBe(0) + + const drained = q.drain() + await Bun.sleep(0) + + release() + await drained + await Bun.sleep(0) + + // Shutdown suppresses re-enqueue; queue and timers must be empty. + expect(sched.size()).toBe(0) + }) + + test("drain resolves when the bound expires on a never-settling flush", async () => { + const errors: { message: string; data: Record }[] = [] + const sched = scheduler(() => clock.now) + let started = false + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { + error: (message, data) => { + errors.push({ message, data }) + }, + }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => { + started = true + // Never settles — drain must exit via its bound timeout, not the fetch. + return new Promise(() => {}) + }, + }), + }) + + await q.sync("s-bound", [{ type: "session", data: { id: "s-bound" } as any }]) + expect(sched.size()).toBe(1) + + const drained = q.drain() + let drainDone = false + void drained.then(() => { + drainDone = true + }) + + // Drain flushes immediately; fetch hangs and schedules the bound timer. + await Bun.sleep(0) + expect(started).toBe(true) + expect(drainDone).toBe(false) + expect(sched.size()).toBe(1) + expect(sched.nextAt()).toBe(3000) + + // Advance past the 3s bound and fire the drain's internal timeout. + clock.now = 3000 + sched.run() + await drained + await Bun.sleep(0) + + expect(drainDone).toBe(true) + expect(errors.some((e) => e.message === "ingest drain timed out")).toBe(true) + // Bound expiry must not re-enqueue or leave a retry timer. + expect(sched.size()).toBe(0) + }) + + test("session_close into open debounce window reschedules flush earlier", async () => { + const sent: unknown[] = [] + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (_input, init) => { + sent.push(JSON.parse((init?.body as string) ?? "{}")) + return new Response("{}", { status: 200 }) + }, + }), + }) + + // Open a normal ~1s debounce window with a part. + await q.sync("s-term", [{ type: "part", data: { id: "p1" } as any }]) + expect(sched.nextAt()).toBe(1000) + + // session_close must pull the flush forward to now (0 wait). + clock.now = 200 + await q.sync("s-term", [{ type: "session_close", data: { reason: "completed" } }]) + expect(sched.nextAt()).toBe(200) + + sched.run() + await Bun.sleep(0) + expect(sent.length).toBe(1) + const payload = sent[0] as { data: { type: string }[] } + expect(payload.data.map((d) => d.type).sort()).toEqual(["part", "session_close"]) + }) + + test("part/message-only batch still schedules at now + 1000", async () => { + const sched = scheduler(() => clock.now) + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async () => new Response("{}", { status: 200 }), + }), + }) + + clock.now = 500 + await q.sync("s-coalesce", [{ type: "part", data: { id: "p1" } as any }]) + expect(sched.nextAt()).toBe(1500) + + clock.now = 800 + await q.sync("s-coalesce", [{ type: "message", data: { id: "m1" } as any }]) + // Later non-terminal sync must not move the flush earlier. + expect(sched.nextAt()).toBe(1500) + }) + + test("terminal batch respects active retry backoff", async () => { + const sent: unknown[] = [] + const sched = scheduler(() => clock.now) + let attempt = 0 + + const q = IngestQueue.create({ + now: () => clock.now, + setTimeout: sched.setTimeout, + clearTimeout: sched.clearTimeout, + log: { error: () => {} }, + getShare: async () => ({ ingestPath: "/ingest" }), + getClient: async () => ({ + url: "https://ingest.test", + fetch: async (_input, init) => { + attempt += 1 + if (attempt === 1) throw new Error("network") + sent.push(JSON.parse((init?.body as string) ?? "{}")) + return new Response("{}", { status: 200 }) + }, + }), + }) + + await q.sync("s-backoff", [{ type: "session", data: { id: "s-backoff", v: 1 } as any }]) + clock.now = 1000 + sched.run() + await Bun.sleep(0) + // Network fail → backoff 1000ms → due at 2000. + expect(sched.nextAt()).toBe(2000) + + clock.now = 1200 + await q.sync("s-backoff", [{ type: "session_close", data: { reason: "completed" } }]) + // Terminal must still respect retry.until (2000), not fire at now (1200). + expect(sched.nextAt()).toBe(2000) + + clock.now = 2000 + sched.run() + await Bun.sleep(0) + expect(sent.length).toBe(1) + const payload = sent[0] as { data: { type: string }[] } + expect(payload.data.map((d) => d.type).sort()).toEqual(["session", "session_close"]) + }) }) diff --git a/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts b/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts new file mode 100644 index 0000000000..d60d053240 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/worker-shutdown.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test" +import { createWorkerShutdown } from "../../../src/cli/tui/worker-shutdown" + +describe("createWorkerShutdown", () => { + test("invokes drain before dispose and stopServer", async () => { + const order: string[] = [] + let resolveDrain!: () => void + const gate = new Promise((resolve) => { + resolveDrain = resolve + }) + + const run = createWorkerShutdown({ + drain: async () => { + order.push("drain-start") + await gate + order.push("drain-end") + }, + dispose: async () => { + order.push("dispose") + }, + stopServer: async () => { + order.push("stopServer") + }, + }) + + const pending = run() + // dispose must not start while drain is still in flight + expect(order).toEqual(["drain-start"]) + + resolveDrain() + await pending + expect(order).toEqual(["drain-start", "drain-end", "dispose", "stopServer"]) + }) + + test("awaits drain fully before dispose even when drain is slow", async () => { + const order: string[] = [] + const run = createWorkerShutdown({ + drain: async () => { + order.push("drain") + await Promise.resolve() + await Promise.resolve() + }, + dispose: async () => { + order.push("dispose") + }, + stopServer: async () => { + order.push("stop") + }, + }) + + await run() + expect(order.indexOf("drain")).toBeLessThan(order.indexOf("dispose")) + expect(order.indexOf("dispose")).toBeLessThan(order.indexOf("stop")) + }) +}) From 61c86222ce3dac159cbef4c64c89c401c44740cc Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Mon, 27 Jul 2026 12:27:11 +0200 Subject: [PATCH 11/35] fix(ui): refine auto-approval line for subagent and todo tools --- .../kilo-ui/src/components/basic-tool.css | 7 +++ .../kilo-ui/src/components/basic-tool.tsx | 16 +++++- .../kilo-ui/src/components/message-part.tsx | 31 ++++++++-- .../src/components/tool-approval.test.ts | 57 +++++++++++++++++++ .../kilo-ui/src/components/tool-approval.tsx | 15 +++-- 5 files changed, 111 insertions(+), 15 deletions(-) create mode 100644 packages/kilo-ui/src/components/tool-approval.test.ts diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index c4495f862f..3c67c36e2a 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty font-family: var(--font-family-mono); } } + +/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex), + so force it onto its own full-width line directly under the title/description. */ +[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] { + flex-basis: 100%; + padding: 2px 0 0; +} diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index ce545b832e..f9bba8db7f 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps { tool?: string callID?: string partID?: string + approvalPlacement?: "body" | "hidden" } type OpenProps = Pick @@ -19,26 +20,35 @@ export function initialOpen(props: OpenProps) { return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen) } +export function useToolApprovalLine() { + const approval = useToolApproval() + return () => { + const value = approval() + return value ? : null + } +} + export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) const initial = () => initialOpen(props) const approval = useToolApproval() + // "hidden" means BasicTool must not inject the line (the card renders it itself, or it is omitted). + const inBody = () => props.approvalPlacement !== "hidden" && approval() !== undefined const change = (open: boolean) => { writeToolOpen(key(), open) props.onOpenChange?.(open) } - // The "why was this allowed" line lives in the expanded body, above any tool-specific details. const details = () => (
{(value) => } {props.children}
) - if (!("children" in props) && !approval()) { + if (!("children" in props) && !inBody()) { return } return ( - + {details()} ) diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 73e917d3c5..26f678681e 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -31,7 +31,7 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, BasicTool } from "./basic-tool" +import { BasicTool, useToolApprovalLine } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" @@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { }} - resolveToolApproval(meta(), i18n.t as (k: string, p?: Record) => string)}> + + resolveToolApproval( + meta(), + i18n.t as (k: string, p?: Record) => string, + ) + } + > - - {(el) => {el()}} - + {(el) => {el()}} @@ -2172,6 +2177,8 @@ ToolRegistry.register({ }, 50) } + const approvalLine = useToolApprovalLine() + const trigger = () => (
@@ -2190,11 +2197,22 @@ ToolRegistry.register({ + {/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */} + {approvalLine()}
) - return + return ( + + ) }, }) @@ -2932,6 +2950,7 @@ ToolRegistry.register({ ) => + params + ? `${key}(${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(",")})` + : key + +describe("resolveToolApproval", () => { + test("returns undefined when there is no approval on the metadata", () => { + expect(resolveToolApproval(undefined, t)).toBeUndefined() + expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined() + }) + + test("manual approvals show only the decision, no source or rule", () => { + const out = resolveToolApproval({ approval: { source: "manual" } }, t) + expect(out).toEqual({ + approval: { source: "manual" }, + decision: "ui.approval.manual", + source: undefined, + rule: undefined, + }) + }) + + test("a specific rule is shown with permission + pattern", () => { + const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } } + const out = resolveToolApproval({ approval }, t) + expect(out?.decision).toBe("ui.approval.auto") + expect(out?.source).toBe("ui.approval.source.project") + expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)") + }) + + test("a per-tool rule with a wildcard pattern still shows the tool name", () => { + const approval = { + source: "agent" as const, + agent: "explore", + rule: { permission: "task", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)") + }) + + test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => { + // e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default. + const approval = { + source: "agent" as const, + agent: "code", + rule: { permission: "*", pattern: "*", action: "allow" }, + } + const out = resolveToolApproval({ approval }, t) + expect(out?.source).toBe("ui.approval.source.agent(agent=code)") + expect(out?.rule).toBeUndefined() + }) +}) diff --git a/packages/kilo-ui/src/components/tool-approval.tsx b/packages/kilo-ui/src/components/tool-approval.tsx index af07af86dd..d555e105df 100644 --- a/packages/kilo-ui/src/components/tool-approval.tsx +++ b/packages/kilo-ui/src/components/tool-approval.tsx @@ -59,13 +59,18 @@ export function resolveToolApproval( if (approval.source === "manual") return undefined return t(`ui.approval.source.${approval.source}`) } + const rule = approval.rule + // The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default), + // so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval. + const ruleText = + rule && !(rule.permission === "*" && rule.pattern === "*") + ? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern }) + : undefined return { approval, decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"), source: sourceText(), - rule: approval.rule - ? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern }) - : undefined, + rule: ruleText, } } @@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
{props.display.decision} - - {(text) => {text()}} - + {(text) => {text()}} {(text) => {text()}}
From d93537cd7342cead841c88886182e6512ac8d02b Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Mon, 27 Jul 2026 12:45:37 +0200 Subject: [PATCH 12/35] fix(ui): actually hide auto-approval line for hidden placement --- .../kilo-ui/src/components/basic-tool.test.ts | 19 +++++++++++++++++++ .../kilo-ui/src/components/basic-tool.tsx | 12 +++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 packages/kilo-ui/src/components/basic-tool.test.ts diff --git a/packages/kilo-ui/src/components/basic-tool.test.ts b/packages/kilo-ui/src/components/basic-tool.test.ts new file mode 100644 index 0000000000..c4009ba55f --- /dev/null +++ b/packages/kilo-ui/src/components/basic-tool.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { shouldRenderApprovalInBody } from "./basic-tool" + +describe("shouldRenderApprovalInBody", () => { + test("renders in the body by default when an approval exists", () => { + expect(shouldRenderApprovalInBody(undefined, true)).toBe(true) + expect(shouldRenderApprovalInBody("body", true)).toBe(true) + }) + + test("does not render when there is no approval", () => { + expect(shouldRenderApprovalInBody("body", false)).toBe(false) + expect(shouldRenderApprovalInBody(undefined, false)).toBe(false) + }) + + test("never renders in the body for hidden placement, even with an approval", () => { + expect(shouldRenderApprovalInBody("hidden", true)).toBe(false) + expect(shouldRenderApprovalInBody("hidden", false)).toBe(false) + }) +}) diff --git a/packages/kilo-ui/src/components/basic-tool.tsx b/packages/kilo-ui/src/components/basic-tool.tsx index f9bba8db7f..7dbaeee939 100644 --- a/packages/kilo-ui/src/components/basic-tool.tsx +++ b/packages/kilo-ui/src/components/basic-tool.tsx @@ -28,19 +28,25 @@ export function useToolApprovalLine() { } } +/** + * Whether BasicTool should inject the approval line into its body. + */ +export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) { + return placement !== "hidden" && hasApproval +} + export function BasicTool(props: BasicToolProps) { const key = () => toolOpenKey(props) const initial = () => initialOpen(props) const approval = useToolApproval() - // "hidden" means BasicTool must not inject the line (the card renders it itself, or it is omitted). - const inBody = () => props.approvalPlacement !== "hidden" && approval() !== undefined + const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined) const change = (open: boolean) => { writeToolOpen(key(), open) props.onOpenChange?.(open) } const details = () => (
- {(value) => } + {(value) => } {props.children}
) From 65c5e9d2c03cea152b140710228075edf9156def Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 27 Jul 2026 14:07:28 +0200 Subject: [PATCH 13/35] fix(vscode): sync mode cycling state (#12560) --- .changeset/fix-scoped-mode-cycling.md | 5 ++ packages/kilo-vscode/package.json | 4 +- packages/kilo-vscode/src/KiloProvider.ts | 14 ++++++ packages/kilo-vscode/src/extension.ts | 4 +- .../kilo-vscode/src/kilo-provider/options.ts | 2 + .../tests/unit/extension-arch.test.ts | 17 +++++++ .../tests/unit/session-agent.test.ts | 49 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 17 ++++--- packages/kilo-vscode/webview-ui/src/App.tsx | 17 ++++--- .../webview-ui/src/context/session-agent.ts | 17 +++++++ .../webview-ui/src/context/vscode.tsx | 6 +++ .../src/types/messages/webview-messages.ts | 6 +++ 12 files changed, 139 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-scoped-mode-cycling.md diff --git a/.changeset/fix-scoped-mode-cycling.md b/.changeset/fix-scoped-mode-cycling.md new file mode 100644 index 0000000000..d71009746d --- /dev/null +++ b/.changeset/fix-scoped-mode-cycling.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs. diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index e67360acec..403c574dca 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -804,13 +804,13 @@ "command": "kilo-code.new.cycleAgentMode", "key": "ctrl+.", "mac": "cmd+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.cyclePreviousAgentMode", "key": "ctrl+shift+.", "mac": "cmd+shift+.", - "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + "when": "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.autocomplete.cancelSuggestions", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 72f6f4378f..943b5dba3b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -741,6 +741,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private setSidebarVisible(visible: boolean): void { this.setStreamVisibility(visible) vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible) + if (!visible && this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } } /** Resolve a WebviewPanel for displaying Kilo in an editor tab. */ @@ -981,6 +984,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return } if (await this.handleModelSelectorExpandedMessage(message)) return + this.handleWebviewFocusMessage(message) this.visibleTaskStreams.handle(message) if (await this.handleMemoryMessage(message)) return if (this.handleLegacyMigrationMessage(message)) return @@ -1466,6 +1470,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable) } + private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown }): void { + if (message.type !== "webviewFocusChanged") return + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true) + } + } + private handleEditorOpenMessage(message: Parameters[0]): boolean { return handleEditorAction(message, { dir: () => this.getWorkspaceDirectory(this.currentSession?.id), @@ -4559,6 +4570,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Does NOT kill the server — that's the connection service's job. */ dispose(): void { + if (this.opts.focusContext) { + void vscode.commands.executeCommand("setContext", this.opts.focusContext, false) + } this.unsubscribeRemote?.() this.streams.focus(undefined) this.connectionService.unregisterVisible(this.instanceId) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 902f29832a..c750637d18 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -122,7 +122,9 @@ export function activate(context: vscode.ExtensionContext) { } // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService, context) + const provider = new KiloProvider(context.extensionUri, connectionService, context, { + focusContext: "kilo-code.new.sidebarFocused", + }) provider.setRemoteService(remoteService) // Register the webview view provider for the sidebar. diff --git a/packages/kilo-vscode/src/kilo-provider/options.ts b/packages/kilo-vscode/src/kilo-provider/options.ts index f32caa4736..63b76a2766 100644 --- a/packages/kilo-vscode/src/kilo-provider/options.ts +++ b/packages/kilo-vscode/src/kilo-provider/options.ts @@ -1,4 +1,6 @@ export type KiloProviderOptions = { + /** Context key updated from focus events reported by this provider's webview. */ + focusContext?: string projectDirectory?: string | null platform?: string snapshotInitialization?: "wait" diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 1e7ed0edeb..1485db0e78 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -127,6 +127,23 @@ describe("Extension — package.json command sync", () => { when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'", }) }) + + it("scopes agent mode shortcuts to focused Kilo webviews", () => { + const bindings = pkg.contributes?.keybindings?.filter( + (item: { command: string }) => + item.command === "kilo-code.new.cycleAgentMode" || item.command === "kilo-code.new.cyclePreviousAgentMode", + ) + const when = + "kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" + + expect(bindings).toHaveLength(2) + expect(bindings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: "kilo-code.new.cycleAgentMode", when }), + expect.objectContaining({ command: "kilo-code.new.cyclePreviousAgentMode", when }), + ]), + ) + }) }) // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/tests/unit/session-agent.test.ts b/packages/kilo-vscode/tests/unit/session-agent.test.ts index d72839a6f8..a6da4e4e17 100644 --- a/packages/kilo-vscode/tests/unit/session-agent.test.ts +++ b/packages/kilo-vscode/tests/unit/session-agent.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "bun:test" import { + cycleAgent, createDraftAgentSeed, draftAgentSelection, resolveSessionAgent, @@ -81,6 +82,54 @@ describe("resolveSessionAgent", () => { }) }) +describe("cycleAgent", () => { + const agents = [ + { name: "ask", mode: "primary" }, + { name: "plan", mode: "primary" }, + { name: "task", mode: "subagent" }, + { name: "hidden", mode: "primary", hidden: true }, + { name: "code", mode: "primary" }, + ] + + function cycle(current: string, direction: 1 | -1, scope = "pending-1") { + const calls: Array<[string, string | undefined]> = [] + const name = cycleAgent({ + agents, + scope, + direction, + selected: (id) => { + expect(id).toBe(scope) + return current + }, + select: (agent, id) => calls.push([agent, id]), + }) + return { name, calls } + } + + it("cycles the same pending scope read by the visible selector", () => { + expect(cycle("ask", 1)).toEqual({ name: "plan", calls: [["plan", "pending-1"]] }) + expect(cycle("ask", -1)).toEqual({ name: "code", calls: [["code", "pending-1"]] }) + }) + + it("wraps and starts from the first agent when the selection is unknown", () => { + expect(cycle("code", 1).name).toBe("ask") + expect(cycle("missing", 1).name).toBe("ask") + }) + + it("does nothing when there is no alternative", () => { + const selected: string[] = [] + expect( + cycleAgent({ + agents: [{ name: "code" }], + direction: 1, + selected: () => "code", + select: (name) => selected.push(name), + }), + ).toBeUndefined() + expect(selected).toEqual([]) + }) +}) + describe("draftAgentSelection", () => { it("carries a pending agent into a new draft scope", () => { const result = draftAgentSelection({}, "draft-1", "plan") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 60252b3977..92da93f772 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -160,6 +160,7 @@ import { buildShortcutCategories } from "./shortcuts" import { tracker } from "./telemetry" import "./agent-manager.css" import "./agent-manager-review.css" +import { cycleAgent as cycle } from "../src/context/session-agent" const REVIEW_TAB_ID = "review" interface SetupState { @@ -1071,14 +1072,14 @@ const AgentManagerContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? activePendingId() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const syncRunStatuses = (items: RunStatus[] = []) => { diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index 1ac8a5dc7c..1aa6768823 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -42,6 +42,7 @@ import { FeedbackProvider } from "./context/feedback" import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models" import { ImageModelsProvider } from "./context/image-models" import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2" +import { cycleAgent as cycle } from "./context/session-agent" import "./styles/chat.css" type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer" @@ -276,14 +277,14 @@ const AppContent: Component = () => { } const cycleAgent = (direction: 1 | -1) => { - const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden) - if (available.length <= 1) return - const current = session.selectedAgent() - const idx = available.findIndex((a) => a.name === current) - const raw = idx + direction - const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw - const agent = available[next] - if (agent) session.selectAgent(agent.name) + const id = session.currentSessionID() ?? tabs?.pending() ?? session.draftSessionID() + cycle({ + agents: session.agents(), + scope: id, + direction, + selected: session.selectedAgent, + select: session.selectAgent, + }) } const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => { diff --git a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts index 86764e307a..18676c8032 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-agent.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-agent.ts @@ -1,5 +1,22 @@ import type { Message } from "../types/messages" +export function cycleAgent(input: { + agents: Array<{ name: string; mode?: string; hidden?: boolean }> + scope?: string + direction: 1 | -1 + selected: (scope?: string) => string + select: (name: string, scope?: string) => void +}) { + const available = input.agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden) + if (available.length <= 1) return + const index = available.findIndex((agent) => agent.name === input.selected(input.scope)) + const raw = index + input.direction + const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw + const name = available[next]?.name + if (name) input.select(name, input.scope) + return name +} + export function resolveSessionAgent(messages: Message[], names: Set): string | undefined { for (let i = messages.length - 1; i >= 0; i--) { const name = messages[i]?.agent?.trim() diff --git a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx index e446cb0760..7ad30c2ddd 100644 --- a/packages/kilo-vscode/webview-ui/src/context/vscode.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/vscode.tsx @@ -55,6 +55,10 @@ export const VSCodeProvider: ParentComponent = (props) => { } window.addEventListener("message", messageListener) + const reportFocus = () => api.postMessage({ type: "webviewFocusChanged", focused: document.hasFocus() }) + window.addEventListener("focus", reportFocus) + window.addEventListener("blur", reportFocus) + reportFocus() handlers.add((message) => { if (message?.type === "modelSelectorExpandedLoaded") setExpanded(message.value) }) @@ -62,6 +66,8 @@ export const VSCodeProvider: ParentComponent = (props) => { onCleanup(() => { window.removeEventListener("message", messageListener) + window.removeEventListener("focus", reportFocus) + window.removeEventListener("blur", reportFocus) handlers.clear() }) diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 0d64477c94..2852e699e6 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -175,6 +175,11 @@ export interface WebviewReadyRequest { type: "webviewReady" } +export interface WebviewFocusChangedRequest { + type: "webviewFocusChanged" + focused: boolean +} + export interface SelectSourceRequest { type: "selectSource" id: string @@ -1257,6 +1262,7 @@ export type WebviewMessage = | CancelLoginRequest | SetOrganizationRequest | WebviewReadyRequest + | WebviewFocusChangedRequest | SelectSourceRequest | RequestProvidersMessage | CompactRequest From 1f3383cf3de37327b02e0fc2a1c5ac176ca9134f Mon Sep 17 00:00:00 2001 From: Aarav Date: Mon, 27 Jul 2026 06:53:43 -0600 Subject: [PATCH 14/35] fix(tui): restore variant cycle keybind hint in prompt footer (#12463) The Ctrl+T variant cycling shortcut hint was rendered in the TUI prompt footer hint row, gated on the active model exposing reasoning variants. It was accidentally removed during an upstream refactor (commit 81eb6e670b in anomalyco/opencode, "refactor(prompt): remove variant cycle display from footer"). - Add useCommandShortcut("variant.cycle") alongside the existing agent and command palette shortcuts - Render the hint as the first item in the footer row, matching the original placement before agents/commands - Gate visibility on local.model.variant.list().length > 0, matching the original upstream guard so the hint is discoverable as soon as the model exposes variants, regardless of whether one is currently selected --- .changeset/tui-variant-shortcut-hint.md | 5 +++++ packages/tui/src/component/prompt/index.tsx | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/tui-variant-shortcut-hint.md diff --git a/.changeset/tui-variant-shortcut-hint.md b/.changeset/tui-variant-shortcut-hint.md new file mode 100644 index 0000000000..8891876bdb --- /dev/null +++ b/.changeset/tui-variant-shortcut-hint.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 6baa0b229e..e237c0c1b6 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -177,6 +177,7 @@ export function Prompt(props: PromptProps) { const keymap = useOpencodeKeymap() const agentShortcut = useCommandShortcut("agent.cycle") const paletteShortcut = useCommandShortcut("command.palette.show") + const variantShortcut = useCommandShortcut("variant.cycle") const renderer = useRenderer() const exit = useExit() const dimensions = useTerminalDimensions() @@ -1835,6 +1836,11 @@ export function Prompt(props: PromptProps) {
+ 0}> + + {variantShortcut()} variants + + {(item) => ( From b31833a24eed44c9a780e919d48f65682b7144eb Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 27 Jul 2026 13:11:50 +0000 Subject: [PATCH 15/35] release(jetbrains): v7.0.11 --- packages/kilo-jetbrains/CHANGELOG.md | 24 +++++++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index d8619ecfc1..0158d690aa 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -102,6 +102,30 @@ ## [Unreleased] +## [7.0.11] - 2026-07-27 + +### Added +- feat: daily docs-sync bot keeping kilo-docs in sync with merged PRs by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12512 +- feat(jetbrains): publish bundled CLI builds on GitHub by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12518 + +### Fixed +- fix(tui): silence optional Kilo Pass failures by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12515 +- fix(cli): bound skill discovery in non-git projects by @LCZcn96 in https://github.com/Kilo-Org/kilocode/pull/12475 +- fix(ci): pass KILO_ORG_ID to docs-sync LLM steps by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12519 +- fix(vscode): preserve active editor pane by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12410 +- fix(cli): emit run events once by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12505 +- fix(cli): stabilize cross-platform subprocess tests by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12514 +- fix(cli): support adaptive thinking for Claude 5+ by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12544 +- fix(cli): flush the session ingest tail on shutdown by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12545 +- fix(vscode): sync mode cycling state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12560 +- fix(tui): restore variant cycle keybind hint in prompt footer by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12463 + +### Changed +- release(jetbrains): v7.0.10 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12516 +- chore(jetbrains): bump CLI pin to v7.4.16 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12517 +- Advertise the instance from enableRemote and report attention status on the heartbeat by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12532 + + ## [7.0.10] - 2026-07-24 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 6782f16844..7b80c74995 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.10 +kilo.jetbrains.version=7.0.11 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From c12a567f2ab3cd46c4c97a950977ed90f568183d Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Mon, 27 Jul 2026 09:14:31 -0400 Subject: [PATCH 16/35] test(cli): cover TUI startup outside package (#12417) * test(cli): cover TUI startup outside package * fix(cli): use native preload path in TUI test --------- Co-authored-by: Johnny Eric Amancio --- .../opencode/script/kilocode/test-profile.ts | 1 + .../test/kilocode/cli/tui/thread.test.ts | 78 +++++++++++++++++++ .../test/kilocode/test-profile.test.ts | 1 + 3 files changed, 80 insertions(+) diff --git a/packages/opencode/script/kilocode/test-profile.ts b/packages/opencode/script/kilocode/test-profile.ts index ed4a61eb52..5e3e222636 100644 --- a/packages/opencode/script/kilocode/test-profile.ts +++ b/packages/opencode/script/kilocode/test-profile.ts @@ -21,6 +21,7 @@ export namespace TestProfile { "cli/serve/*.test.ts", "kilocode/background-process.test.ts", "kilocode/cli/install-artifact.test.ts", + "kilocode/cli/tui/thread.test.ts", "kilocode/core-watcher.test.ts", "kilocode/interactive-terminal.test.ts", "tool/shell.test.ts", diff --git a/packages/opencode/test/kilocode/cli/tui/thread.test.ts b/packages/opencode/test/kilocode/cli/tui/thread.test.ts index 20e4289415..a17fd1725a 100644 --- a/packages/opencode/test/kilocode/cli/tui/thread.test.ts +++ b/packages/opencode/test/kilocode/cli/tui/thread.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import fs from "fs/promises" import path from "path" +import { fileURLToPath } from "node:url" +import { spawn, type Exit } from "@opencode-ai/core/pty/driver" +import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process" import { tmpdir } from "../../../fixture/fixture" import { embeddedRemoteExitClient, @@ -41,6 +44,81 @@ describe("kilo tui thread", () => { expect(calls).toBe(1) }) + test( + "starts the TUI from a directory without OpenTUI dependencies", + async () => { + await using root = await tmpdir() + const state = { text: "", exit: undefined as Exit | undefined } + const ready = Promise.withResolvers() + const stopped = Promise.withResolvers() + const proc = spawn( + process.execPath, + [ + "--conditions=browser", + `--preload=${fileURLToPath(import.meta.resolve("@opentui/solid/preload"))}`, + path.resolve(import.meta.dir, "../../../../src/index.ts"), + ], + { + name: "xterm-256color", + cols: 120, + rows: 40, + cwd: root.path, + env: sanitizedProcessEnv({ + HOME: root.path, + XDG_CONFIG_HOME: path.join(root.path, ".config"), + XDG_DATA_HOME: path.join(root.path, ".local/share"), + XDG_STATE_HOME: path.join(root.path, ".local/state"), + XDG_CACHE_HOME: path.join(root.path, ".cache"), + KILO_TEST_HOME: root.path, + KILO_CONFIG_CONTENT: "{}", + KILO_AUTH_CONTENT: "{}", + KILO_DISABLE_PROJECT_CONFIG: "1", + KILO_DISABLE_AUTOUPDATE: "1", + KILO_DISABLE_MODELS_FETCH: "1", + KILO_DISABLE_TERMINAL_TITLE: "0", + KILO_DEV_CWD: "", + KILO_PURE: "1", + KILO_NO_DAEMON: "1", + TERM: "xterm-256color", + }), + }, + ) + const data = proc.onData((chunk) => { + state.text = (state.text + chunk).slice(-20_000) + if (state.text.includes("TUI worker error")) { + ready.reject(new Error(`TUI worker failed during startup:\n${state.text}`)) + return + } + // The title is emitted only after the worker-backed TUI reaches its rendered app. + if (state.text.includes("Kilo CLI")) ready.resolve() + }) + const exit = proc.onExit((event) => { + state.exit = event + stopped.resolve() + ready.reject( + new Error( + `TUI exited before rendering (code ${event.exitCode}, signal ${event.signal ?? "none"}):\n${state.text}`, + ), + ) + }) + const timer = setTimeout(() => { + ready.reject(new Error(`Timed out waiting for the TUI to render:\n${state.text}`)) + }, 30_000) + + try { + await ready.promise + expect(state.text).toContain("Kilo CLI") + } finally { + clearTimeout(timer) + data.dispose() + if (!state.exit) proc.kill() + await stopped.promise + exit.dispose() + } + }, + 45_000, + ) + test("ignores stale PWD after cwd is changed by a process wrapper", async () => { await using root = await tmpdir() const pkg = path.join(root.path, "packages", "opencode") diff --git a/packages/opencode/test/kilocode/test-profile.test.ts b/packages/opencode/test/kilocode/test-profile.test.ts index 7e37cc27d1..a9abc16759 100644 --- a/packages/opencode/test/kilocode/test-profile.test.ts +++ b/packages/opencode/test/kilocode/test-profile.test.ts @@ -14,6 +14,7 @@ describe("test profiles", () => { expect(result.files.length).toBeGreaterThan(20) expect(result.files).toContain("pty/pty-shell.test.ts") expect(result.files).toContain("kilocode/cli/install-artifact.test.ts") + expect(result.files).toContain("kilocode/cli/tui/thread.test.ts") expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts") expect(result.files).toContain("kilocode/core-watcher.test.ts") expect(result.files).toContain("kilocode/background-process.test.ts") From c96a6792028e3b821d7c67987eefbfc8e43aa973 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Mon, 27 Jul 2026 09:24:39 -0400 Subject: [PATCH 17/35] docs(jetbrains): edit changelog for v7.0.11 --- packages/kilo-jetbrains/CHANGELOG.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 0158d690aa..25ba496db5 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -105,26 +105,19 @@ ## [7.0.11] - 2026-07-27 ### Added -- feat: daily docs-sync bot keeping kilo-docs in sync with merged PRs by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12512 -- feat(jetbrains): publish bundled CLI builds on GitHub by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12518 + +- Add a signed GitHub-hosted bundled JetBrains plugin build that includes the Kilo CLI for offline or restricted-network installs. ### Fixed -- fix(tui): silence optional Kilo Pass failures by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12515 -- fix(cli): bound skill discovery in non-git projects by @LCZcn96 in https://github.com/Kilo-Org/kilocode/pull/12475 -- fix(ci): pass KILO_ORG_ID to docs-sync LLM steps by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12519 -- fix(vscode): preserve active editor pane by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12410 -- fix(cli): emit run events once by @lambertjosh in https://github.com/Kilo-Org/kilocode/pull/12505 -- fix(cli): stabilize cross-platform subprocess tests by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12514 -- fix(cli): support adaptive thinking for Claude 5+ by @chrarnoldus in https://github.com/Kilo-Org/kilocode/pull/12544 -- fix(cli): flush the session ingest tail on shutdown by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12545 -- fix(vscode): sync mode cycling state by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12560 -- fix(tui): restore variant cycle keybind hint in prompt footer by @IamCoder18 in https://github.com/Kilo-Org/kilocode/pull/12463 + +- Load global skills reliably from JetBrains projects that are not inside a Git repository. +- Support adaptive thinking for Claude Opus and Sonnet 5+ model identifiers across Anthropic, AI Gateway, and Bedrock providers. +- Flush pending cloud session updates when the Kilo Core runtime shuts down, reducing cases where the final assistant message is missing when a session is reopened elsewhere. +- Prune stale bundled CLI versions after upgrading bundled JetBrains installs. ### Changed -- release(jetbrains): v7.0.10 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12516 -- chore(jetbrains): bump CLI pin to v7.4.16 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12517 -- Advertise the instance from enableRemote and report attention status on the heartbeat by @iscekic in https://github.com/Kilo-Org/kilocode/pull/12532 +- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16. ## [7.0.10] - 2026-07-24 From d7baa1e67bcc25180977467f5ff6de388cf250a5 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Mon, 27 Jul 2026 09:26:17 -0400 Subject: [PATCH 18/35] Apply suggestion from @kilo-code-bot[bot] Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- packages/kilo-jetbrains/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 25ba496db5..dd9239dbb4 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -118,7 +118,7 @@ ### Changed - Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16. - +## [7.0.10] - 2026-07-24 ## [7.0.10] - 2026-07-24 ### Added From 44f596366931d5336f1cd4dfdd97ef54e0f2fa4c Mon Sep 17 00:00:00 2001 From: Marius Date: Mon, 27 Jul 2026 15:32:15 +0200 Subject: [PATCH 19/35] fix(vscode): prevent recursive settings saves (#12561) * fix(vscode): prevent recursive settings saves * docs: clarify settings save release note --- .changeset/fix-vscode-settings-save.md | 5 +++++ .../kilo-ui/src/components/select-change.ts | 4 ++++ packages/kilo-ui/src/components/select.test.ts | 17 +++++++++++++++++ packages/kilo-ui/src/components/select.tsx | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+) create mode 100644 .changeset/fix-vscode-settings-save.md create mode 100644 packages/kilo-ui/src/components/select-change.ts create mode 100644 packages/kilo-ui/src/components/select.test.ts diff --git a/.changeset/fix-vscode-settings-save.md b/.changeset/fix-vscode-settings-save.md new file mode 100644 index 0000000000..c7ed178ad9 --- /dev/null +++ b/.changeset/fix-vscode-settings-save.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix settings changes sometimes failing to save and apply in VS Code. diff --git a/packages/kilo-ui/src/components/select-change.ts b/packages/kilo-ui/src/components/select-change.ts new file mode 100644 index 0000000000..220e979a34 --- /dev/null +++ b/packages/kilo-ui/src/components/select-change.ts @@ -0,0 +1,4 @@ +export function changed(current: T | undefined, next: T | undefined, key: (item: T) => string) { + if (current === undefined || next === undefined) return current !== next + return key(current) !== key(next) +} diff --git a/packages/kilo-ui/src/components/select.test.ts b/packages/kilo-ui/src/components/select.test.ts new file mode 100644 index 0000000000..cc81ce944a --- /dev/null +++ b/packages/kilo-ui/src/components/select.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test" +import { changed } from "./select-change" + +describe("changed", () => { + const key = (item: { value: string }) => item.value + + test("ignores recreated options with the current key", () => { + expect(changed({ value: "ollama" }, { value: "ollama" }, key)).toBe(false) + }) + + test("reports selected and cleared values", () => { + expect(changed({ value: "ollama" }, { value: "kilo" }, key)).toBe(true) + expect(changed({ value: "ollama" }, undefined, key)).toBe(true) + expect(changed(undefined, { value: "ollama" }, key)).toBe(true) + expect(changed(undefined, undefined, key)).toBe(false) + }) +}) diff --git a/packages/kilo-ui/src/components/select.tsx b/packages/kilo-ui/src/components/select.tsx index d4a6c72f2c..d6a72b1a8d 100644 --- a/packages/kilo-ui/src/components/select.tsx +++ b/packages/kilo-ui/src/components/select.tsx @@ -1 +1,19 @@ +import { Select as Base, type SelectProps } from "@opencode-ai/ui/select" +import type { ButtonProps } from "@opencode-ai/ui/button" +import { changed } from "./select-change" + export * from "@opencode-ai/ui/select" + +export function Select(props: SelectProps & Omit) { + const key = (item: T) => (props.value ? props.value(item) : (item as string)) + + return ( + { + if (!changed(props.current, next, key)) return + props.onSelect?.(next) + }} + /> + ) +} From 2da89498138e49f857c354924fdecac85337e742 Mon Sep 17 00:00:00 2001 From: Thomas Brugman Date: Mon, 27 Jul 2026 15:49:03 +0200 Subject: [PATCH 20/35] fix(ui): preserve parenthesized tildes in markdown (#12540) --- .changeset/fuzzy-tildes-smile.md | 5 ++++ packages/ui/src/context/marked.tsx | 11 ++++++++ .../kilocode/markdown-strikethrough.test.ts | 26 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .changeset/fuzzy-tildes-smile.md create mode 100644 packages/ui/src/kilocode/markdown-strikethrough.test.ts diff --git a/.changeset/fuzzy-tildes-smile.md b/.changeset/fuzzy-tildes-smile.md new file mode 100644 index 0000000000..39c76209f8 --- /dev/null +++ b/.changeset/fuzzy-tildes-smile.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve parenthesized tilde expressions as literal text in rendered chat messages. diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 050b700526..33af8ff08e 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -321,6 +321,17 @@ export const createMarkedParser = (props: { nativeParser?: NativeMarkdownParser }, // kilocode_change end }, + // kilocode_change start: Marked accepts a tilde preceded by an opening + // parenthesis as the closing delimiter. It is left-flanking there, so + // preserve it literally instead of corrupting text such as "(~1 GB)". + tokenizer: { + del(src) { + const match = this.rules.inline.del.exec(src) + if (match?.[0].at(-2) === "(") return + return false + }, + }, + // kilocode_change end }, // kilocode_change start: enable only double-dollar math. // Single $ is far more common as a currency symbol in agent responses diff --git a/packages/ui/src/kilocode/markdown-strikethrough.test.ts b/packages/ui/src/kilocode/markdown-strikethrough.test.ts new file mode 100644 index 0000000000..3ef4c42e51 --- /dev/null +++ b/packages/ui/src/kilocode/markdown-strikethrough.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { createMarkedParser } from "../context/marked" + +describe("Markdown strikethrough boundaries", () => { + test.each(["(~a (~b", "~a (~b", "(~24 GB) and (~5.7 GB)", "(~/.config) and (~/.cache)"])( + "preserves parenthesized tildes in %s", + async (text) => { + const parser = createMarkedParser({}) + const html = await Promise.resolve(parser.parse(text)) + + expect(html).not.toContain("") + expect(html).toContain(text) + }, + ) + + test.each([ + ["~removed~", "removed"], + ["~~removed~~", "removed"], + ["(~removed~)", "(removed)"], + ])("keeps valid strikethrough syntax in %s", async (text, expected) => { + const parser = createMarkedParser({}) + const html = await Promise.resolve(parser.parse(text)) + + expect(html).toContain(expected) + }) +}) From 51d660319f8fde2782fdeeeb130960c8c9f39cdc Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 10:59:57 -0400 Subject: [PATCH 21/35] fix(jetbrains): split bundled publish Gradle tasks --- .github/workflows/publish-jetbrains-bundled.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index 56b331680e..53de4637bd 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -145,11 +145,17 @@ jobs: - name: Build signed bundled plugin working-directory: packages/kilo-jetbrains run: | - ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \ - -Pproduction=true \ - -Pkilo.version="$VERSION" \ - -Pkilo.channel="$CHANNEL" \ + args=( + -Pproduction=true + -Pkilo.version="$VERSION" + -Pkilo.channel="$CHANNEL" -Pkilo.cli.bundled=true + ) + + ./gradlew clean buildPlugin "${args[@]}" + ./gradlew signPlugin "${args[@]}" + ./gradlew verifyPluginSignature "${args[@]}" + ./gradlew verifyPlugin "${args[@]}" env: GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} From 2da0cac53f8a730cfc7bfa5e4d68e4fc15e2b06c Mon Sep 17 00:00:00 2001 From: kirillk <166173+kirillk@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:56:30 +0000 Subject: [PATCH 22/35] fix(jetbrains): use file-based signing secrets for bundled publish workflow Passing the JETBRAINS_CERTIFICATE_CHAIN / JETBRAINS_PRIVATE_KEY multiline secret content directly as certificateChain/privateKey Gradle properties gets mishandled by the zip-signer CLI when signPlugin and verifyPluginSignature run as separate Gradle invocations (#12567), causing verifyPluginSignature to fail with 'Invalid argument: ***' as the masked multiline content is split into extra CLI args. Mirror script/build-version.sh: write the certificate chain and private key to temp files under $RUNNER_TEMP and wire certificateChainFile/privateKeyFile (file-based) into the intellij signing extension instead of certificateChain/privateKey (raw content). Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../workflows/publish-jetbrains-bundled.yml | 25 +++++++++++++++++-- packages/kilo-jetbrains/build.gradle.kts | 11 ++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index 53de4637bd..8f0134b15c 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -142,6 +142,22 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Write signing secrets to temp files + run: | + dir="$RUNNER_TEMP/jetbrains-signing" + mkdir -m 700 -p "$dir" + chain="$dir/certificate-chain.pem" + key="$dir/private-key.pem" + umask 077 + printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain" + printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key" + chmod 600 "$chain" "$key" + echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV" + echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" + env: + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + - name: Build signed bundled plugin working-directory: packages/kilo-jetbrains run: | @@ -152,6 +168,9 @@ jobs: -Pkilo.cli.bundled=true ) + # signPlugin and verifyPluginSignature run as separate Gradle invocations (see #12567) + # so the JETBRAINS_CERTIFICATE_CHAIN_FILE / JETBRAINS_PRIVATE_KEY_FILE env vars set in + # the previous step (rather than raw multiline secret content) must be present for both. ./gradlew clean buildPlugin "${args[@]}" ./gradlew signPlugin "${args[@]}" ./gradlew verifyPluginSignature "${args[@]}" @@ -161,10 +180,12 @@ jobs: GITHUB_TOKEN: ${{ github.token }} VERSION: ${{ needs.validate.outputs.version }} CHANNEL: ${{ needs.validate.outputs.channel }} - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Remove signing secret temp files + if: always() + run: rm -rf "$RUNNER_TEMP/jetbrains-signing" + - name: Resolve bundled archive id: archive run: | diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index c13a70c2b6..3ad08aff36 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -207,8 +207,15 @@ intellijPlatform { } signing { - certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") - privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") + // File-based inputs are preferred over raw content: certificateChain/privateKey take + // precedence over certificateChainFile/privateKeyFile in the IntelliJ Platform Gradle + // plugin, and passing multiline PEM content straight through as a certificateChain/ + // privateKey value can get mishandled as extra CLI arguments by the zip-signer CLI when + // signPlugin/verifyPluginSignature run as separate Gradle invocations. Both CI + // (.github/workflows/publish-jetbrains-bundled.yml) and local releases + // (script/build-version.sh) write the secrets to files and export the *_FILE variables. + certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { file(it) }) + privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { file(it) }) password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") } From b00a8e4d879f122138ed46d2656a022a196383d2 Mon Sep 17 00:00:00 2001 From: emilieschario <14057155+emilieschario@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:13:02 +0000 Subject: [PATCH 23/35] docs: note that bot-generated PRs are ignored by default in Code Reviews Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-docs/pages/automate/code-reviews/overview.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index a78b4ae4a3..4f310b6642 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -159,6 +159,10 @@ When a pull request or merge request is opened or updated: Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer. +{% callout type="info" title="Bot-generated PRs are ignored by default" %} +Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. If you want bot PRs reviewed, request this on the Code Reviews beta Discord channel. +{% /callout %} + ## Review Styles ### Strict @@ -236,3 +240,4 @@ The Review Agent is ideal for: - Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`. - The agent will only run on **selected repositories**. - During beta, review capacity may be throttled for extremely large PRs. +- PRs/MRs opened by bots (e.g. Dependabot, Renovate) are ignored by default. From 0178e3361b5df6ed138281bd98d9ded90b66f524 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 12:14:33 -0400 Subject: [PATCH 24/35] fix(jetbrains): handle queued prompt deletion misses --- .../session/controller/SessionController.kt | 12 ++++++++++-- .../client/session/views/MessageToolbar.kt | 17 +---------------- .../kilocode/client/session/views/TurnView.kt | 1 - .../session/controller/PromptLifecycleTest.kt | 13 +++++++++++++ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index ec3299f6b0..9b1e517ac6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -467,11 +467,19 @@ class SessionController( fun deleteQueuedMessage(message: String) { assertEdt() val id = sid ?: return - capture("Conversation Queued Message Removed", sessionProps(id)) cs.launch { try { - sessions.deleteMessage(id, directory, message) + val ok = sessions.deleteMessage(id, directory, message) + if (!ok) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to "DeleteMiss")) + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage missed message=$message") + return@launch + } + capture("Conversation Queued Message Removed", sessionProps(id)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to e::class.java.name)) LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt index 1df7642dc3..4ffeee1446 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageToolbar.kt @@ -33,7 +33,6 @@ internal class MessageToolbar( buttons.forEach { next(it) } next(button) } - private var custom: JComponent? = null init { isOpaque = false @@ -42,12 +41,10 @@ internal class MessageToolbar( @RequiresEdt fun sync(value: Boolean) { - val controls = customButtons() - if (isVisible == value && button.isEnabled == value && controls.all { it.isEnabled == value }) return + if (isVisible == value && button.isEnabled == value) return isVisible = value button.isEnabled = value buttons.forEach { it.isEnabled = value } - controls.forEach { it.isEnabled = value } revalidate() repaint() } @@ -63,16 +60,6 @@ internal class MessageToolbar( @RequiresEdt fun copyButton() = button - @RequiresEdt - fun setCustom(node: JComponent?) { - if (custom === node) return - remove(custom ?: row) - custom = node - add(node ?: row) - revalidate() - repaint() - } - fun placeholder(): JComponent = object : JPanel() { init { isOpaque = false @@ -91,6 +78,4 @@ internal class MessageToolbar( copy.dismiss() super.removeNotify() } - - private fun customButtons() = custom?.components?.filterIsInstance().orEmpty() } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index cc3cc7d74c..8723fc4ce5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -67,7 +67,6 @@ class TurnView( val view = MessageView(msg, openFile, style, openUrl, selection, openAttachment, resize, repo, hover, revert) messages[msg.info.id] = view add(view) - if (msg.info.id == id && deleteQueued != null) view.setQueued(false) { deleteQueued.invoke(id) } syncCopyToolbars() revalidate() return view diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index 05dd2e9a6e..9d5e4225e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -127,6 +127,19 @@ class PromptLifecycleTest : SessionControllerTestBase() { flush() assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertTrue(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + } + + fun `test delete queued message miss captures error`() { + val (c, _, _) = prompted() + rpc.messageDeleteResult = false + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertFalse(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + assertTrue(appRpc.telemetry.any { it.event == "Session Error" && it.properties["context"] == "delete-message" }) } fun `test PermissionAsked moves state to AwaitingPermission`() { From e204035659be972a2346311e5dbb86e4b73e2ec2 Mon Sep 17 00:00:00 2001 From: Emilie Lima Schario <14057155+emilieschario@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:24:55 -0400 Subject: [PATCH 25/35] Apply suggestion from @emilieschario --- packages/kilo-docs/pages/automate/code-reviews/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index 4f310b6642..b5930f0c20 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -160,7 +160,7 @@ When a pull request or merge request is opened or updated: Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer. {% callout type="info" title="Bot-generated PRs are ignored by default" %} -Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. If you want bot PRs reviewed, request this on the Code Reviews beta Discord channel. +Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. I {% /callout %} ## Review Styles From c1ebda7c59b9cd024b84fdc66f0da8abbf840d7e Mon Sep 17 00:00:00 2001 From: Emilie Lima Schario <14057155+emilieschario@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:31:23 -0400 Subject: [PATCH 26/35] Update packages/kilo-docs/pages/automate/code-reviews/overview.md --- packages/kilo-docs/pages/automate/code-reviews/overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index b5930f0c20..40e1109389 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -160,7 +160,7 @@ When a pull request or merge request is opened or updated: Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer. {% callout type="info" title="Bot-generated PRs are ignored by default" %} -Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. I +Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. {% /callout %} ## Review Styles From da6d20a32444e71d121cbb6eed90935439b906f8 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 12:31:41 -0400 Subject: [PATCH 27/35] fix(jetbrains): localize queued prompt labels --- .../src/main/resources/messages/KiloBundle_ar.properties | 2 ++ .../src/main/resources/messages/KiloBundle_bs.properties | 2 ++ .../src/main/resources/messages/KiloBundle_da.properties | 2 ++ .../src/main/resources/messages/KiloBundle_de.properties | 2 ++ .../src/main/resources/messages/KiloBundle_es.properties | 2 ++ .../src/main/resources/messages/KiloBundle_fr.properties | 2 ++ .../src/main/resources/messages/KiloBundle_ja.properties | 2 ++ .../src/main/resources/messages/KiloBundle_ko.properties | 2 ++ .../src/main/resources/messages/KiloBundle_nl.properties | 2 ++ .../src/main/resources/messages/KiloBundle_no.properties | 2 ++ .../src/main/resources/messages/KiloBundle_pl.properties | 2 ++ .../src/main/resources/messages/KiloBundle_pt_BR.properties | 2 ++ .../src/main/resources/messages/KiloBundle_ru.properties | 2 ++ .../src/main/resources/messages/KiloBundle_th.properties | 2 ++ .../src/main/resources/messages/KiloBundle_tr.properties | 2 ++ .../src/main/resources/messages/KiloBundle_uk.properties | 2 ++ .../src/main/resources/messages/KiloBundle_zh_CN.properties | 2 ++ .../src/main/resources/messages/KiloBundle_zh_TW.properties | 2 ++ 18 files changed, 36 insertions(+) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index f439e04158..999dcb7c54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -23,6 +23,8 @@ session.copy.hover=نسخ session.copy.prompt=نسخ الموجه session.copy.response=نسخ الرد session.copy.copied=تم النسخ +session.queued=في قائمة الانتظار +session.queued.remove=إزالة الرسالة من قائمة الانتظار session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 3c3fe1f71a..fb61208a23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiraj session.copy.prompt=Kopiraj prompt session.copy.response=Kopiraj odgovor session.copy.copied=Kopirano +session.queued=U redu čekanja +session.queued.remove=Ukloni poruku iz reda čekanja session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 8d0d26a722..a785788ba0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiér session.copy.prompt=Kopiér prompt session.copy.response=Kopiér svar session.copy.copied=Kopieret +session.queued=I kø +session.queued.remove=Fjern besked fra køen session.tab.new=Ny session session.tab.untitled=Unavngivet session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 181ac9f165..837e4d8dd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopieren session.copy.prompt=Prompt kopieren session.copy.response=Antwort kopieren session.copy.copied=Kopiert +session.queued=In Warteschlange +session.queued.remove=Nachricht aus Warteschlange entfernen session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index fb67a0de55..25279f3492 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar respuesta session.copy.copied=Copiado +session.queued=En cola +session.queued.remove=Eliminar mensaje en cola session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 3848ae7ee9..3c279709d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -23,6 +23,8 @@ session.copy.hover=Copier session.copy.prompt=Copier le prompt session.copy.response=Copier la réponse session.copy.copied=Copié +session.queued=En attente +session.queued.remove=Supprimer le message en attente session.tab.new=Nouvelle session session.tab.untitled=Session sans titre diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 8c9e3d12c0..9d853d2a79 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -23,6 +23,8 @@ session.copy.hover=コピー session.copy.prompt=プロンプトをコピー session.copy.response=応答をコピー session.copy.copied=コピーしました +session.queued=キュー済み +session.queued.remove=キュー済みメッセージを削除 session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index b463a9b195..9b1797052c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -23,6 +23,8 @@ session.copy.hover=복사 session.copy.prompt=프롬프트 복사 session.copy.response=응답 복사 session.copy.copied=복사됨 +session.queued=대기 중 +session.queued.remove=대기 중인 메시지 제거 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index b29e1b9dc2..85fc3f6b31 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiëren session.copy.prompt=Prompt kopiëren session.copy.response=Antwoord kopiëren session.copy.copied=Gekopieerd +session.queued=In wachtrij +session.queued.remove=Bericht uit wachtrij verwijderen session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index c577c42052..c160fdf400 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopier session.copy.prompt=Kopier prompt session.copy.response=Kopier svar session.copy.copied=Kopiert +session.queued=I kø +session.queued.remove=Fjern melding fra køen session.tab.new=Ny økt session.tab.untitled=Uten tittel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index d57e58ac71..db9776e10b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiuj session.copy.prompt=Kopiuj prompt session.copy.response=Kopiuj odpowiedź session.copy.copied=Skopiowano +session.queued=W kolejce +session.queued.remove=Usuń wiadomość z kolejki session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 6655ce84b0..35dda74bfe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar resposta session.copy.copied=Copiado +session.queued=Na fila +session.queued.remove=Remover mensagem da fila session.tab.new=Nova sessão session.tab.untitled=Sessão sem título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index c1eaee006a..3d53c6262d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -23,6 +23,8 @@ session.copy.hover=Копировать session.copy.prompt=Скопировать промпт session.copy.response=Скопировать ответ session.copy.copied=Скопировано +session.queued=В очереди +session.queued.remove=Удалить сообщение из очереди session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 89e6d9a8df..3b400b23d0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -23,6 +23,8 @@ session.copy.hover=คัดลอก session.copy.prompt=คัดลอกพรอมต์ session.copy.response=คัดลอกคำตอบ session.copy.copied=คัดลอกแล้ว +session.queued=อยู่ในคิว +session.queued.remove=ลบข้อความในคิว session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index d6ee1e84d9..8b7a2de3cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopyala session.copy.prompt=Promptu kopyala session.copy.response=Yanıtı kopyala session.copy.copied=Kopyalandı +session.queued=Kuyrukta +session.queued.remove=Kuyruktaki mesajı kaldır session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index f385d236f4..896ee37e7a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -23,6 +23,8 @@ session.copy.hover=Копіювати session.copy.prompt=Скопіювати промпт session.copy.response=Скопіювати відповідь session.copy.copied=Скопійовано +session.queued=У черзі +session.queued.remove=Видалити повідомлення з черги session.tab.new=Нова сесія session.tab.untitled=Сесія без назви diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 3f4b3fbc3d..864bce7a28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -23,6 +23,8 @@ session.copy.hover=复制 session.copy.prompt=复制提示词 session.copy.response=复制回复 session.copy.copied=已复制 +session.queued=已排队 +session.queued.remove=移除排队消息 session.tab.new=新建会话 session.tab.untitled=无标题会话 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index dd2898d09a..48c5418ae7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -23,6 +23,8 @@ session.copy.hover=複製 session.copy.prompt=複製提示詞 session.copy.response=複製回覆 session.copy.copied=已複製 +session.queued=已排入佇列 +session.queued.remove=移除佇列中的訊息 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 From 3083fdb02b179b20255e1727826ae68874fd32c7 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 15:08:07 -0400 Subject: [PATCH 28/35] fix(jetbrains): avoid writing release signing secrets --- .../workflows/publish-jetbrains-bundled.yml | 31 ++----------------- packages/kilo-jetbrains/build.gradle.kts | 20 ++++++------ 2 files changed, 14 insertions(+), 37 deletions(-) diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index 8f0134b15c..d28afe473d 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -142,22 +142,6 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - name: Write signing secrets to temp files - run: | - dir="$RUNNER_TEMP/jetbrains-signing" - mkdir -m 700 -p "$dir" - chain="$dir/certificate-chain.pem" - key="$dir/private-key.pem" - umask 077 - printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain" - printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key" - chmod 600 "$chain" "$key" - echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV" - echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" - env: - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} - - name: Build signed bundled plugin working-directory: packages/kilo-jetbrains run: | @@ -167,25 +151,16 @@ jobs: -Pkilo.channel="$CHANNEL" -Pkilo.cli.bundled=true ) - - # signPlugin and verifyPluginSignature run as separate Gradle invocations (see #12567) - # so the JETBRAINS_CERTIFICATE_CHAIN_FILE / JETBRAINS_PRIVATE_KEY_FILE env vars set in - # the previous step (rather than raw multiline secret content) must be present for both. - ./gradlew clean buildPlugin "${args[@]}" - ./gradlew signPlugin "${args[@]}" - ./gradlew verifyPluginSignature "${args[@]}" - ./gradlew verifyPlugin "${args[@]}" + ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}" env: GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} VERSION: ${{ needs.validate.outputs.version }} CHANNEL: ${{ needs.validate.outputs.channel }} + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} - - name: Remove signing secret temp files - if: always() - run: rm -rf "$RUNNER_TEMP/jetbrains-signing" - - name: Resolve bundled archive id: archive run: | diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 3ad08aff36..0ff583165a 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.tasks.InstrumentCodeTask import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.PluginInstallationTarget +import java.io.File import java.time.LocalDate group = "ai.kilocode.jetbrains" @@ -207,15 +208,12 @@ intellijPlatform { } signing { - // File-based inputs are preferred over raw content: certificateChain/privateKey take - // precedence over certificateChainFile/privateKeyFile in the IntelliJ Platform Gradle - // plugin, and passing multiline PEM content straight through as a certificateChain/ - // privateKey value can get mishandled as extra CLI arguments by the zip-signer CLI when - // signPlugin/verifyPluginSignature run as separate Gradle invocations. Both CI - // (.github/workflows/publish-jetbrains-bundled.yml) and local releases - // (script/build-version.sh) write the secrets to files and export the *_FILE variables. - certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { file(it) }) - privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { file(it) }) + // CI passes raw secret content so signing can run without writing secrets to disk. + // Local release builds can still point these properties at pre-existing secret files. + certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") + certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { File(it) }) + privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { File(it) }) password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") } @@ -227,6 +225,10 @@ intellijPlatform { } tasks { + named("verifyPluginSignature") { + dependsOn("signPlugin") + } + withType { enabled = false } From e096b17f4e85afec753fb1a0cf274133d3339805 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 27 Jul 2026 20:01:25 +0000 Subject: [PATCH 29/35] release(jetbrains): v7.0.12-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 19 +++++++++++++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index dd9239dbb4..f6b8314e04 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -102,6 +102,25 @@ ## [Unreleased] +## [7.0.12-rc.1] - 2026-07-27 + +### Added +- feat(jetbrains): support queued prompts by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12571 + +### Fixed +- fix(ui): refine auto-approval line for subagent and todo tools by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12556 +- fix(vscode): prevent recursive settings saves by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12561 +- fix(ui): preserve parenthesized tildes in markdown by @Githubguy132010 in https://github.com/Kilo-Org/kilocode/pull/12540 +- fix(jetbrains): split bundled publish Gradle tasks by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12567 +- fix(jetbrains): use file-based signing secrets for bundled publish workflow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12570 +- fix(jetbrains): avoid writing release signing secrets by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12575 + +### Changed +- test(cli): cover TUI startup outside package by @shssoichiro in https://github.com/Kilo-Org/kilocode/pull/12417 +- release(jetbrains): v7.0.11 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12563 +- docs: note that bot-generated PRs are ignored by default in Code Reviews by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/12572 + + ## [7.0.11] - 2026-07-27 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 7b80c74995..90722cc658 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.11 +kilo.jetbrains.version=7.0.12-rc.1 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From 9cca61539603911ef0c07b4e5f7324eb669b8073 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Mon, 27 Jul 2026 16:14:20 -0400 Subject: [PATCH 30/35] docs(jetbrains): edit changelog for v7.0.12-rc.1 --- packages/kilo-jetbrains/CHANGELOG.md | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index f6b8314e04..84d5c8c113 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -105,21 +105,8 @@ ## [7.0.12-rc.1] - 2026-07-27 ### Added -- feat(jetbrains): support queued prompts by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12571 - -### Fixed -- fix(ui): refine auto-approval line for subagent and todo tools by @bagatao-anaconda in https://github.com/Kilo-Org/kilocode/pull/12556 -- fix(vscode): prevent recursive settings saves by @marius-kilocode in https://github.com/Kilo-Org/kilocode/pull/12561 -- fix(ui): preserve parenthesized tildes in markdown by @Githubguy132010 in https://github.com/Kilo-Org/kilocode/pull/12540 -- fix(jetbrains): split bundled publish Gradle tasks by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12567 -- fix(jetbrains): use file-based signing secrets for bundled publish workflow by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12570 -- fix(jetbrains): avoid writing release signing secrets by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12575 - -### Changed -- test(cli): cover TUI startup outside package by @shssoichiro in https://github.com/Kilo-Org/kilocode/pull/12417 -- release(jetbrains): v7.0.11 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12563 -- docs: note that bot-generated PRs are ignored by default in Code Reviews by @emilieschario in https://github.com/Kilo-Org/kilocode/pull/12572 +- Support sending another JetBrains prompt while a session is still running. Queued prompts now appear in the conversation and can be removed before Kilo starts processing them. ## [7.0.11] - 2026-07-27 From 2d9ac3a86feb34d33fe65161f32b1cc17268a411 Mon Sep 17 00:00:00 2001 From: kirillk Date: Mon, 27 Jul 2026 16:59:40 -0400 Subject: [PATCH 31/35] fix(jetbrains): use file signing inputs for bundled publish --- .../workflows/publish-jetbrains-bundled.yml | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index d28afe473d..5908b25e73 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -142,6 +142,22 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Write signing secrets to temp files + run: | + dir="$RUNNER_TEMP/jetbrains-signing" + mkdir -m 700 -p "$dir" + chain="$dir/certificate-chain.pem" + key="$dir/private-key.pem" + umask 077 + printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain" + printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key" + chmod 600 "$chain" "$key" + echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV" + echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" + env: + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + - name: Build signed bundled plugin working-directory: packages/kilo-jetbrains run: | @@ -157,10 +173,12 @@ jobs: GITHUB_TOKEN: ${{ github.token }} VERSION: ${{ needs.validate.outputs.version }} CHANNEL: ${{ needs.validate.outputs.channel }} - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Remove signing secret temp files + if: always() + run: rm -rf "$RUNNER_TEMP/jetbrains-signing" + - name: Resolve bundled archive id: archive run: | From de105b79da2eba0e0aad04630dcead1f27bc3eab Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 27 Jul 2026 21:16:38 +0000 Subject: [PATCH 32/35] release(jetbrains): v7.0.12-rc.2 --- packages/kilo-jetbrains/CHANGELOG.md | 9 +++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 84d5c8c113..f5d838a70f 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -102,6 +102,15 @@ ## [Unreleased] +## [7.0.12-rc.2] - 2026-07-27 + +### Fixed +- fix(jetbrains): use file signing inputs for bundled publish by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12578 + +### Changed +- release(jetbrains): v7.0.12-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12577 + + ## [7.0.12-rc.1] - 2026-07-27 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 90722cc658..9e9e7cd62d 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.12-rc.1 +kilo.jetbrains.version=7.0.12-rc.2 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. From ea605a20dd1828183236e3e7499e3af6cab318ac Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Mon, 27 Jul 2026 17:22:11 -0400 Subject: [PATCH 33/35] docs(jetbrains): edit changelog for v7.0.12-rc.2 --- packages/kilo-jetbrains/CHANGELOG.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index f5d838a70f..7c48f6085e 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -104,12 +104,13 @@ ## [7.0.12-rc.2] - 2026-07-27 +### Added + ### Fixed -- fix(jetbrains): use file signing inputs for bundled publish by @kirillk in https://github.com/Kilo-Org/kilocode/pull/12578 + +- Fix the GitHub-hosted bundled JetBrains plugin build so signing uses certificate and private-key files during verification. ### Changed -- release(jetbrains): v7.0.12-rc.1 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/12577 - ## [7.0.12-rc.1] - 2026-07-27 From a0a760e00e915a800125f03db7e08381ddc63e2a Mon Sep 17 00:00:00 2001 From: Marius Date: Tue, 28 Jul 2026 09:50:32 +0200 Subject: [PATCH 34/35] fix(cli): enforce permissions on shell commands the parser fails to scan (#12585) * fix(cli): enforce permissions on shell commands the parser fails to scan * fix(cli): fail closed on error chunks without command names, move pwsh execution test to kilo file --- .changeset/pwsh-permission-fail-closed.md | 5 + .../src/kilocode/tool/shell-unparsed.ts | 22 ++ packages/opencode/src/tool/shell.ts | 9 + .../test/kilocode/tool/shell-unparsed.test.ts | 232 ++++++++++++++++++ 4 files changed, 268 insertions(+) create mode 100644 .changeset/pwsh-permission-fail-closed.md create mode 100644 packages/opencode/src/kilocode/tool/shell-unparsed.ts create mode 100644 packages/opencode/test/kilocode/tool/shell-unparsed.test.ts diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md new file mode 100644 index 0000000000..cb140f33cd --- /dev/null +++ b/.changeset/pwsh-permission-fail-closed.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. diff --git a/packages/opencode/src/kilocode/tool/shell-unparsed.ts b/packages/opencode/src/kilocode/tool/shell-unparsed.ts new file mode 100644 index 0000000000..3680f2eb73 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/shell-unparsed.ts @@ -0,0 +1,22 @@ +import type { Node } from "web-tree-sitter" + +// tree-sitter-powershell drops commands containing a bare `--` into ERROR nodes +// instead of command nodes, so the shell permission scanner collected zero +// patterns and skipped the check entirely (Kilo-Org/kilocode#12326). Recover +// the failed command text from ERROR nodes, and fail closed with the raw input +// whenever the parse has errors and nothing else was recovered, so every +// executed command yields at least one permission pattern. The raw fallback +// also covers ERROR chunks without a command_name descendant (for example +// PowerShell backtick escapes), which can still contain runnable text. +export function unparsed(root: Node, commands: number): string[] { + if (!root.hasError && commands > 0) return [] + const failed = root + .descendantsOfType("ERROR") + .filter((node): node is Node => Boolean(node)) + .filter((node) => node.descendantsOfType("command_name").length > 0) + .map((node) => node.text.trim()) + .filter((text) => text.length > 0) + if (failed.length > 0) return failed + const raw = root.text.trim() + return raw ? [raw] : [] +} diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 429a04c7f9..05c22e5cda 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -20,6 +20,7 @@ import { Plugin } from "@/plugin" import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change import { heredocs } from "@/kilocode/tool/shell-heredoc" // kilocode_change +import { unparsed } from "@/kilocode/tool/shell-unparsed" // kilocode_change import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" @@ -404,6 +405,14 @@ export const ShellPermission = Effect.gen(function* () { } } + // kilocode_change start - fail closed on commands the grammar failed to parse (#12326) + const lost = unparsed(root, nodes.length) + if (lost.length > 0) scan.access = "unknown" + for (const pattern of lost) { + scan.patterns.add(pattern) + } + // kilocode_change end + return scan }) diff --git a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts new file mode 100644 index 0000000000..e9818cdbef --- /dev/null +++ b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts @@ -0,0 +1,232 @@ +// Regression tests for Kilo-Org/kilocode#12326. +// +// tree-sitter-powershell dropped commands containing a bare `--` (for example +// `git checkout -- `) into ERROR nodes instead of command nodes, so the +// shell permission scanner collected zero patterns and the command executed +// with no permission evaluation at all, bypassing every bash rule including +// `"git *": "deny"` and `"*": "deny"`. The scanner now fails closed: failed +// command text is recovered from ERROR nodes, and any parse with errors that +// recovered nothing falls back to the raw command text (also covering ERROR +// chunks without a command_name descendant, such as backtick escapes). + +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import path from "path" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Permission } from "../../../src/permission" +import { ShellPermission } from "../../../src/tool/shell" +import { ShellTool } from "../../../src/tool/shell" +import { Shell } from "../../../src/shell/shell" +import { Config } from "../../../src/config/config" +import { Agent } from "../../../src/agent/agent" +import { Plugin } from "../../../src/plugin" +import { Truncate } from "../../../src/tool/truncate" +import { RuntimeFlags } from "../../../src/effect/runtime-flags" +import { SessionID, MessageID } from "../../../src/session/schema" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../../fixture/fixture" +import { afterEach } from "bun:test" + +const layer = Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer) + +type ScanRequest = Omit + +async function scan(dir: string, command: string, shell: string) { + const requests: ScanRequest[] = [] + const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: (req: ScanRequest) => + Effect.sync(() => { + requests.push(req) + }), + } + await Effect.runPromise( + provideInstance(dir)( + Effect.gen(function* () { + const permission = yield* ShellPermission + yield* permission.ask(ctx, { command, cwd: dir, shell, description: "test" }) + }), + ).pipe(Effect.provide(layer)), + ) + return requests +} + +function patterns(requests: ScanRequest[]) { + return requests.filter((req) => req.permission === "bash").flatMap((req) => req.patterns) +} + +const deny = Permission.fromConfig({ + "*": "ask", + bash: { + "*": "ask", + "git *": "deny", + }, +}) + +function action(pattern: string) { + return Permission.evaluate("bash", pattern, deny).action +} + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("shell permission scanner fails closed on unparsed commands", () => { + test("pwsh: bare '--' git commands now produce a denied pattern", async () => { + await using tmp = await tmpdir() + for (const command of ["git checkout -- file", "git restore -- file", "git log -- file", "git checkout -- ."]) { + const found = patterns(await scan(tmp.path, command, "pwsh")) + expect(found.length).toBeGreaterThan(0) + expect(found.map(action)).toContain("deny") + } + }) + + test("pwsh: bare '--' in a chained command no longer vanishes from the check", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file; git status", "pwsh")) + expect(found).toContain("git status") + expect(found.some((pattern) => pattern.includes("git checkout -- file"))).toBe(true) + expect(found.map(action)).toContain("deny") + }) + + test("pwsh: bare '--' in non-git commands produces a pattern that falls back to ask", async () => { + await using tmp = await tmpdir() + for (const command of ["npm run build -- --watch", "echo -- hi", "rm -rf -- file"]) { + const found = patterns(await scan(tmp.path, command, "pwsh")) + expect(found.length).toBeGreaterThan(0) + expect(found.map(action)).toContain("ask") + } + }) + + test("pwsh: valid commands are unchanged (no extra patterns, no new prompts)", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, "git status", "pwsh"))).toEqual(["git status"]) + expect(patterns(await scan(tmp.path, 'git checkout "--" file', "pwsh"))).toEqual(['git checkout "--" file']) + const found = patterns(await scan(tmp.path, "Write-Host foo; if ($?) { Write-Host bar }", "pwsh")) + expect(found).toContain("Write-Host foo") + expect(found).toContain("Write-Host bar") + expect(found.length).toBe(2) + }) + + test("pwsh: whitespace stays silent, comment-only input is checked instead of trusted", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, " ", "pwsh"))).toEqual([]) + expect(patterns(await scan(tmp.path, "# comment only", "pwsh"))).toEqual(["# comment only"]) + }) + + test("bash grammar: behavior is unchanged for direct, chained, and location commands", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, "git checkout -- file", "bash"))).toEqual(["git checkout -- file"]) + const chained = patterns(await scan(tmp.path, `cd ${tmp.path} && git checkout -- file`, "bash")) + expect(chained).toEqual(["git checkout -- file"]) + expect(patterns(await scan(tmp.path, `cd ${tmp.path}`, "bash"))).toEqual([]) + }) + + test("cmd-kind: bare '--' git commands still produce a denied pattern", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file", "cmd")) + expect(found).toEqual(["git checkout -- file"]) + expect(found.map(action)).toContain("deny") + }) + + test("pwsh: runnable text in an ERROR node without command_name falls back to the raw check", async () => { + await using tmp = await tmpdir() + // PowerShell interprets `n as a newline escape, so this input executes + // `git checkout -- file`, but the grammar drops that segment into an ERROR + // node with no command_name descendant while `echo ok` parses cleanly. + const found = patterns(await scan(tmp.path, "echo ok; `ngit checkout -- file", "pwsh")) + expect(found).toContain("echo ok; `ngit checkout -- file") + expect(found.map(action)).toContain("ask") + }) + + test("pwsh: partially parsed pipelines still fail closed with the raw text", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file | cat", "pwsh")) + expect(found).toContain("git checkout -- file | cat") + expect(found.map(action)).toContain("deny") + }) +}) + +const execLayer = Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + FSUtil.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Config.defaultLayer, + Agent.defaultLayer, + RuntimeFlags.defaultLayer, + testInstanceStoreLayer, +) + +const powershells = + process.platform === "win32" + ? [Bun.which("pwsh"), Bun.which("powershell")].filter((shell): shell is string => Boolean(shell)) + : [] + +async function withShell(shell: string, fn: () => Promise) { + const prev = process.env.SHELL + process.env.SHELL = shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + if (prev !== undefined) process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +// End-to-end coverage through the real shell tool and a real PowerShell binary. +// Runs only on the Windows CI runners, where pwsh/powershell exist. +describe("full tool execution through real powershell (windows only)", () => { + for (const shell of powershells) { + test(`asks for permission on a bare double dash command [${path.basename(shell, ".exe")}]`, async () => { + await using tmp = await tmpdir() + const requests: ScanRequest[] = [] + const stop = new Error("stop after permission") + await withShell(shell, () => + Effect.runPromise( + provideInstance(tmp.path)( + Effect.gen(function* () { + const info = yield* ShellTool + const tool = yield* info.init() + const exit = yield* tool + .execute( + { command: "git checkout -- file", description: "Restore a file from git" }, + { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: (req: ScanRequest) => + Effect.sync(() => { + requests.push(req) + throw stop + }), + }, + ) + .pipe(Effect.exit) + const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(err instanceof Error && err.message).toBe(stop.message) + }), + ).pipe(Effect.provide(execLayer)), + ), + ) + const req = requests.find((r) => r.permission === "bash") + expect(req).toBeDefined() + expect(req!.patterns).toContain("git checkout -- file") + }) + } +}) From e4759b75acd1e8b1ad32b248d9b6db6895c9dd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 10:40:39 +0200 Subject: [PATCH 35/35] =?UTF-8?q?fix(ci):=20docs-sync=20bot=20=E2=80=94=20?= =?UTF-8?q?no=20errors,=20no=20timeouts,=20no=20lost=20PRs=20(#12580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ci): configure git identity before the docs-sync merge and classify merge failures * fix(ci): hold the docs-sync watermark back until every PR has an outcome * test(ci): self-check for the docs-sync failure paths * fix(ci): isolate PR selftest concurrency from the daily docs-sync run --- .github/docs-sync/edit.mjs | 138 +++- .github/docs-sync/lib.mjs | 95 +++ .github/docs-sync/prepare-branch.mjs | 110 ++- .github/docs-sync/selftest.mjs | 964 +++++++++++++++++++++++++++ .github/docs-sync/triage.mjs | 162 +++-- .github/docs-sync/upsert-pr.mjs | 260 +++++++- .github/docs-sync/watermark.mjs | 84 ++- .github/workflows/docs-sync.yml | 59 +- 8 files changed, 1720 insertions(+), 152 deletions(-) create mode 100644 .github/docs-sync/selftest.mjs diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 7da66bca64..777c3a2929 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -6,20 +6,22 @@ * Batching bounds each `kilo run` context (a replay window can yield dozens * of docs-worthy PRs with large diffs). Each batch gets its own CLI session * and writes its own summary file; results are merged into - * docs-sync-out/edit-summary.json. A batch that fails is skipped with a - * warning — its PRs show up in the rolling PR body as skipped, so nothing - * fails silently. + * docs-sync-out/edit-summary.json. A batch that fails or is deferred by the + * wall-clock budget is recorded as action "pending" so the watermark holds + * back and the next run re-collects those PRs. * * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). + * Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15). + * Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" +import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const BATCH_SIZE = 5 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" export const SUMMARY_FILE = ".docs-sync-summary.json" @@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") const model = process.env.EDIT_MODEL if (!model) throw new Error("EDIT_MODEL is required") +const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50 +const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15 +const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000 + const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8")) const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8")) const priority = new Map(triage.map((e) => [e.url, e])) @@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => { return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1) }) -function editBatch(batch, index) { +/** @type {Map} url → pending cause for failed/deferred batches */ +const pendingCauses = new Map() + +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function editBatch(batch, index, budgetDeadline) { const batchFile = `${OUT_DIR}/edit-batch-${index}.json` const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json` const summaryFile = `${OUT_DIR}/edit-summary-${index}.json` @@ -54,31 +71,59 @@ function editBatch(batch, index) { Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).` + let lastCause = "edit pass failed" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - execFileSync( - "kilo", - ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], - // stdout streams live to the Actions log; stderr is piped so failure - // warnings can include the tail of the actual CLI error. - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)` + console.warn( + `batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`, ) - if (fs.existsSync(summaryFile)) return true - // Tolerate the agent dropping the docs-sync-out/ prefix. - const alt = path.basename(summaryFile) - if (fs.existsSync(alt)) { - fs.renameSync(alt, summaryFile) - return true + break + } + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + timeoutMs: Math.min(BATCH_TIMEOUT_MS, left), + streamStdout: true, + label: `edit batch ${index} attempt ${attempt}`, + }) + + if (fs.existsSync(summaryFile)) return true + // Tolerate the agent dropping the docs-sync-out/ prefix. + const alt = path.basename(summaryFile) + if (fs.existsSync(alt)) { + fs.renameSync(alt, summaryFile) + return true + } + + // Exit 0 is not success: missing summary is a failure logged WITH the + // captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `edit batch ${index}: ${cause}` + console.warn( + `batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + // Skip the wait when the remaining budget cannot fit another attempt. + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) { + console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) } - console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) } } - console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`) + + console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`) + for (const d of batch) pendingCauses.set(d.url, lastCause) return false } @@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) { } console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`) +const budgetDeadline = deadline(EDIT_BUDGET_MINUTES) +let deferredFrom = -1 + for (let i = 0; i < batches.length; i++) { - editBatch(batches[i], i) + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + deferredFrom = i + const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0) + console.warn( + `stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`, + ) + const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < batches.length; j++) { + for (const d of batches[j]) pendingCauses.set(d.url, cause) + } + break + } + editBatch(batches[i], i, budgetDeadline) +} + +if (deferredFrom >= 0) { + console.warn( + `edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`, + ) } // Merge batch summaries. Coverage: every worthy PR gets an entry so the PR -// body accounts for it; failed batches show up as skipped. +// body accounts for it; failed/deferred batches show up as pending (not skipped). const merged = [] const seen = new Set() for (let i = 0; i < batches.length; i++) { @@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) { const url = String(e?.url ?? "") if (!url.startsWith("http") || seen.has(url)) continue seen.add(url) - merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") }) + merged.push({ + pr: Number(e.pr) || 0, + url, + action: String(e.action ?? "skipped"), + reason: String(e.reason ?? ""), + }) } } for (const d of ordered) { if (seen.has(d.url)) continue - merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" }) + const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR" + merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause }) } // upsert-pr.mjs consumes the merged summary from the repo root; the file is // removed there before committing so it never lands in the docs PR. fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2)) -console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`) +const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length +const skipped = merged.filter((e) => e.action === "skipped").length +const pending = merged.filter((e) => e.action === "pending").length +console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`) diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 56dfb7b922..67c13354d8 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -6,6 +6,7 @@ * gh CLI. */ +import { spawnSync } from "node:child_process" import fs from "node:fs" const API = "https://api.github.com" @@ -111,3 +112,97 @@ export function appendSummary(markdown) { const summary = process.env.GITHUB_STEP_SUMMARY if (summary) fs.appendFileSync(summary, markdown + "\n") } + +/** + * Absolute deadline timestamp (ms since epoch) for a wall-clock budget. + * Used by triage/edit to stop before the job timeout rather than silently + * truncating. + */ +export function deadline(minutes) { + return Date.now() + Number(minutes) * 60 * 1000 +} + +/** Remaining milliseconds until a deadline; never negative. */ +export function remainingMs(deadlineMs) { + return Math.max(0, Number(deadlineMs) - Date.now()) +} + +/** + * Backoff schedule between kilo-run attempts. Production waits 60s then 300s + * (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When + * DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting); + * the workflow never sets it — only selftests do. + */ +export function backoffMsForAttempt(attempt) { + // attempt is 1-based; wait happens after attempt N before attempt N+1. + const override = process.env.DOCS_SYNC_BACKOFF_MS + if (override !== undefined && override !== "") { + const n = Number(override) + return Number.isFinite(n) && n >= 0 ? n : 0 + } + // After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last. + if (attempt === 1) return 60_000 + if (attempt === 2) return 300_000 + return 0 +} + +/** + * Blocking sleep used between kilo-run retries. Prefer this over async sleep + * so edit/triage stay synchronous around spawnSync. + */ +export function sleepSync(ms) { + const n = Number(ms) + if (!Number.isFinite(n) || n <= 0) return + const end = Date.now() + n + // Atomics.wait is the portable Node sync sleep (no busy loop). + const sab = new SharedArrayBuffer(4) + const view = new Int32Array(sab) + while (Date.now() < end) { + const left = end - Date.now() + if (left <= 0) break + Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647)) + } +} + +const STDERR_TAIL_LINES = 20 +const STDERR_TAIL_CHARS = 4_000 + +function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) { + const s = String(text ?? "").trim() + if (!s) return "" + const lastLines = s.split("\n").slice(-lines).join("\n") + return lastLines.length > chars ? lastLines.slice(-chars) : lastLines +} + +/** + * Run `kilo` via spawnSync so stderr is always recoverable — including when + * the child exits 0 after writing a diagnostic (execFileSync cannot return + * piped stderr on exit 0; that path lost every diagnostic on run 30122603016). + * + * streamStdout:true → inherit fd 1 (edit live log); false → capture stdout + * (triage parses it). stderr is always buffered. + */ +export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) { + const result = spawnSync("kilo", args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: timeoutMs, + stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"], + }) + + const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT") + const exitCode = + typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status + const stderrTail = tailText(result.stderr) + const stdout = streamStdout ? "" : String(result.stdout ?? "") + // ok is "process finished without OS-level failure". Callers still treat a + // missing summary / unparseable output as failure even when ok is true — + // exit 0 is not success for the docs-sync bot. + const ok = !result.error && result.status === 0 + + if (result.error && !timedOut) { + console.warn(`${label}: spawn error: ${result.error.message}`) + } + + return { ok, stdout, stderrTail, exitCode, timedOut } +} diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2b57932c44..2710832ccf 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -6,56 +6,100 @@ * origin/main (preserves any human commits on the branch) * - otherwise -> fresh branch from origin/main (bot force-pushes later) * - * Outputs: branch, mode (update|fresh), pr_number (empty when fresh). + * Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh). */ import { execFileSync } from "node:child_process" +import { pathToFileURL } from "node:url" import { api, appendOutput, repo, searchIssues } from "./lib.mjs" export const DEFAULT_BRANCH = "docs/auto-sync" -const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() +const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() -const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) - -let mode = "fresh" -let prNumber = "" -let branch = DEFAULT_BRANCH - -if (prs.length > 0) { - const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) - branch = pr.head?.ref ?? DEFAULT_BRANCH - prNumber = String(pr.number) - git(["fetch", "origin", "main", branch]) - git(["checkout", branch]) +/** + * Merge origin/main into the current branch. On a genuine conflict, abort the + * merge, switch to a dated fallback branch from origin/main, and return + * mode=conflict so human commits on the rolling branch stay untouched. Any + * other merge failure (missing identity, corrupt ref, fetch issues) is + * rethrown so the job fails loudly. + */ +export function mergeOrFallback({ branch, git = defaultGit }) { try { git(["merge", "origin/main", "--no-edit"]) - mode = "update" - } catch { + return { branch, mode: "update" } + } catch (err) { + // Conflict ⇔ unmerged index entries (or MERGE_HEAD still present). + // Identity failures and similar abort before a merge is started, so + // merge --abort would itself fail — those must rethrow. + let unmerged = "" + try { + unmerged = git(["ls-files", "--unmerged"]) + } catch { + // ls-files itself failing is not a conflict signal + } + let mergeInProgress = false + try { + git(["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + mergeInProgress = true + } catch { + mergeInProgress = false + } + const isConflict = unmerged.length > 0 || mergeInProgress + if (!isConflict) throw err + console.warn(`merge of origin/main into ${branch} conflicted.`) - console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.") + console.warn( + "Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.", + ) git(["merge", "--abort"]) - branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + try { + git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`]) + } catch { + console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`) + } + git(["checkout", "-B", fallback, "origin/main"]) + return { branch: fallback, mode: "conflict" } + } +} + +async function main() { + const git = defaultGit + const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + + let mode = "fresh" + let prNumber = "" + let branch = DEFAULT_BRANCH + + if (prs.length > 0) { + const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) + branch = pr.head?.ref ?? DEFAULT_BRANCH + prNumber = String(pr.number) + git(["fetch", "origin", "main", branch]) + git(["checkout", branch]) + ;({ branch, mode } = mergeOrFallback({ branch, git })) + } else { + // Keep the remote-tracking ref current so the later --force-with-lease + // push (stale branch left over from a merged/closed PR) is safe. try { git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { - console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`) + console.log(`branch ${branch} does not exist on origin yet; will create it on push`) } git(["checkout", "-B", branch, "origin/main"]) - mode = "conflict" } -} else { - // Keep the remote-tracking ref current so the later --force-with-lease - // push (stale branch left over from a merged/closed PR) is safe. - try { - git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) - } catch { - console.log(`branch ${branch} does not exist on origin yet; will create it on push`) - } - git(["checkout", "-B", branch, "origin/main"]) + + appendOutput("branch", branch) + appendOutput("mode", mode) + appendOutput("pr_number", prNumber) + console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) } -appendOutput("branch", branch) -appendOutput("mode", mode) -appendOutput("pr_number", prNumber) -console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs new file mode 100644 index 0000000000..fe2cb1faec --- /dev/null +++ b/.github/docs-sync/selftest.mjs @@ -0,0 +1,964 @@ +// kilocode_change - new file + +/** + * Offline self-check for the docs-sync failure paths (S4). + * Plain node:assert, no network, no LLM, no new dependency. + * Run: node .github/docs-sync/selftest.mjs + */ + +import assert from "node:assert/strict" +import { execFileSync, spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" +import { applyCap } from "./watermark.mjs" +import { + computeUncovered, + computeProcessedThrough, + routeRows, + dropLegacySkipped, + noDiffReport, + renderBody, + extractSectionRows, +} from "./upsert-pr.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const EDIT_SCRIPT = path.join(HERE, "edit.mjs") +const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") +const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") + +const temps = [] + +function mktemp(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + temps.push(dir) + return dir +} + +function cleanup() { + for (const dir of temps.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } +} + +function writeExecutable(filePath, body) { + fs.writeFileSync(filePath, body, { mode: 0o755 }) +} + +function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) { + const dir = mktemp("docs-sync-kilo-") + const kiloPath = path.join(dir, "kilo") + // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const mode = ${JSON.stringify(mode)}; +const callLog = ${JSON.stringify(callLog ?? "")}; +const stderrText = ${JSON.stringify(stderrText)}; +if (callLog) { + fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n"); +} +if (mode === "stderr-exit0") { + process.stderr.write(stderrText + "\\n"); + process.exit(0); +} +if (mode === "record") { + process.stderr.write("recorded\\n"); + process.exit(0); +} +// Parse -f chunk/batch file from args for triage stubs +const args = process.argv.slice(2); +const fIdx = args.indexOf("-f"); +const fileArg = fIdx >= 0 ? args[fIdx + 1] : null; +let chunk = []; +if (fileArg && fs.existsSync(fileArg)) { + try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; } +} +if (mode === "partial-triage") { + // Classify only a proper subset (first URL) of the chunk. + const owned = chunk.slice(0, Math.max(0, chunk.length - 1)); + const entries = owned.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: false, + reason: "genuine not worthy", + target_sections: [], + priority: "medium", + })); + if (entries.length === 0 && chunk.length > 0) { + // single-PR chunk: still leave one missing by emitting empty-ish foreign-only + process.stdout.write("[]\\n"); + } else { + process.stdout.write(JSON.stringify(entries) + "\\n"); + } + process.exit(0); +} +if (mode === "mixed-triage") { + // Half docs_worthy true, half fail (no output for second half — but we return + // only some entries so backfill marks the rest pending). Actually: return + // docs_worthy:true for first half of chunk URLs so worthy > 0. + const half = Math.ceil(chunk.length / 2); + const entries = chunk.slice(0, half).map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })); + process.stdout.write(JSON.stringify(entries) + "\\n"); + process.exit(0); +} +process.stderr.write("unknown stub mode\\n"); +process.exit(1); +` + writeExecutable(kiloPath, script) + return dir +} + +function gitIn(cwd, args, env = {}) { + return execFileSync("git", args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).toString().trim() +} + +function makeGitRunner(cwd, env = {}) { + return (args) => gitIn(cwd, args, env) +} + +function initRepoWithIdentity(dir) { + gitIn(dir, ["init", "-b", "main"]) + gitIn(dir, ["config", "user.name", "docs-sync-selftest"]) + gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"]) + gitIn(dir, ["config", "commit.gpgsign", "false"]) +} + +// --------------------------------------------------------------------------- +// Case 1 — Defect A: mergeOrFallback +// --------------------------------------------------------------------------- +function case1_mergeOrFallback() { + console.log("case 1: Defect A (mergeOrFallback)") + + // 1a — identity configured + clean merge → mode=update + { + const dir = mktemp("docs-sync-merge-clean-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch commit"]) + // Advance main without conflict + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "on main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main advance"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "update") + assert.equal(result.branch, DEFAULT_BRANCH) + // merge brought c.txt in + assert.ok(fs.existsSync(path.join(dir, "c.txt"))) + } + + // 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched + { + const dir = mktemp("docs-sync-merge-conflict-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + const baseSha = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "branch edit"]) + const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "main edit"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "conflict") + assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`)) + // Original rolling branch tip unchanged + const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH]) + assert.equal(branchShaAfter, branchShaBefore) + // No merge in progress + let mergeHead = true + try { + gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + } catch { + mergeHead = false + } + assert.equal(mergeHead, false) + void baseSha + } + + // 1c — identity-less / non-conflict merge failure → throws (does not fake conflict) + { + const dir = mktemp("docs-sync-merge-noid-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch"]) + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + // Strip identity so merge cannot create a commit + gitIn(dir, ["config", "--unset", "user.name"]) + gitIn(dir, ["config", "--unset", "user.email"]) + + const env = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + } + const git = (args) => + execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).toString().trim() + + assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => { + // Must throw the original merge error, not a merge --abort failure + const msg = String(err?.stderr ?? err?.message ?? err) + assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) + return true + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers to run edit.mjs / triage.mjs as child processes +// --------------------------------------------------------------------------- +function setupEditCwd(worthy, triage) { + const cwd = mktemp("docs-sync-edit-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2)) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2)) + return cwd +} + +function setupTriageCwd(digest) { + const cwd = mktemp("docs-sync-triage-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2)) + return cwd +} + +function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) { + const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter) + const result = spawnSync(process.execPath, [scriptPath], { + cwd, + env: { + ...process.env, + ...env, + PATH: pathEnv, + DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0", + }, + encoding: "utf8", + timeout: 60_000, + }) + return { + status: result.status, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + error: result.error, + } +} + +function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) { + return { + repo, + number: n, + title: `feat: sample ${n}`, + url: `https://github.com/${repo}/pull/${n}`, + author: "dev", + merged_at: merged_at ?? "2026-07-20T12:00:00.000Z", + labels: [], + body: "body", + files: [], + files_total: 1, + patch_excerpt: "", + } +} + +// --------------------------------------------------------------------------- +// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr) +// --------------------------------------------------------------------------- +function case2_defectB() { + console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const stderrText = "event stream disconnected DIAG-CASE2" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const started = Date.now() + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + // Enough budget for 3 attempts × tiny timeout + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + const elapsed = Date.now() - started + + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + // Backoff collapsed — 3 attempts without 60s+300s waits + assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`) + + assert.match(result.output, /stderr tail:/) + assert.match(result.output, /DIAG-CASE2|event stream disconnected/) + assert.match(result.output, /attempt 1/) + assert.match(result.output, /attempt 2/) + // 3 attempts + assert.match(result.output, /attempt 3|failed after up to 3 attempts/) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.equal(summary.length, 5) + for (const e of summary) { + assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`) + } + + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 5) + for (const u of uncovered) { + assert.ok(u.reason, "uncovered reason present") + } +} + +// --------------------------------------------------------------------------- +// Case 3 — watermark invariant +// --------------------------------------------------------------------------- +function case3_watermark() { + console.log("case 3: watermark invariant") + + const now = "2026-07-27T12:00:00.000Z" + const nowMs = Date.parse(now) + + const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" }) + const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" }) + const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" }) + const digest = [prA, prB, prC] + + // all covered → processed-through === now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" }, + { pr: 11, url: prB.url, action: "skipped", reason: "already documented" }, + ] + const triage = [ + { pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" }, + { pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 0) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, now) + } + + // one uncovered → merged_at − 1 ms, strictly < now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated x", reason: "" }, + { pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, prB.url) + const through = computeProcessedThrough({ uncovered, digest, now }) + const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString() + assert.equal(through, expected) + assert.ok(Date.parse(through) < nowMs) + } + + // several uncovered → earliest merge time wins + { + const worthy = [prA, prB, prC] + const summary = [ + { pr: 10, url: prA.url, action: "pending", reason: "fail" }, + { pr: 12, url: prC.url, action: "pending", reason: "fail" }, + ] + // prB missing from summary entirely + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.ok(uncovered.length >= 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + // earliest among A, B, C that are uncovered — A is earliest + const times = uncovered + .map((u) => digest.find((d) => d.url === u.url)?.merged_at) + .filter(Boolean) + .map((t) => Date.parse(t)) + const earliest = Math.min(...times) + assert.equal(through, new Date(earliest - 1).toISOString()) + } + + // summary missing/truncated while worthy non-empty → every worthy URL held back + { + const worthy = [prA, prB] + const uncovered = computeUncovered({ worthy, summary: [], triage: [] }) + assert.equal(uncovered.length, 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + } + + // noDiffReport three arms + { + const uncovered = [{ url: prA.url, reason: "edit batch failed" }] + const arm1 = noDiffReport({ uncovered, sinceOverride: true }) + assert.ok(arm1.summary.includes(prA.url)) + assert.ok(arm1.warning, "override + uncovered → warning present") + + const arm2 = noDiffReport({ uncovered: [], sinceOverride: true }) + assert.equal(arm2.warning, null, "override + empty uncovered → warning absent") + + const arm3 = noDiffReport({ uncovered, sinceOverride: false }) + assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent") + } + + // triage pending:true backfill rows land in uncovered (consumption) + { + const triage = [ + { + pr: 99, + url: "https://github.com/Kilo-Org/cloud/pull/99", + docs_worthy: false, + pending: true, + reason: "not classified by triage", + }, + ] + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, triage[0].url) + const through = computeProcessedThrough({ + uncovered, + digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }], + now, + }) + assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString()) + } + + // fallback field (post-plan repair) + { + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }] + const fallback = "2026-07-17T00:00:00.000Z" + // unresolved merged_at + parseable fallback → hold at fallback, warn + const prevWarn = console.warn + const warnings = [] + console.warn = (...a) => warnings.push(a.join(" ")) + try { + const through = computeProcessedThrough({ uncovered, digest: [], now, fallback }) + assert.equal(through, new Date(fallback).toISOString()) + assert.ok(Date.parse(through) < nowMs) + assert.ok(warnings.some((w) => w.includes("::warning::"))) + } finally { + console.warn = prevWarn + } + + // unresolved + unparseable/missing fallback → throws + assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i) + assert.throws( + () => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }), + /fallback|SINCE|refusing/i, + ) + + // resolved merged_at ignores fallback + const throughResolved = computeProcessedThrough({ + uncovered: [{ url: prA.url, reason: "x" }], + digest: [prA], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + + // empty uncovered ignores fallback + const throughEmpty = computeProcessedThrough({ + uncovered: [], + digest: [], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughEmpty, now) + } +} + +// --------------------------------------------------------------------------- +// Case 4 — routing and round trip +// --------------------------------------------------------------------------- +function case4_routing() { + console.log("case 4: routing and round trip") + + const summary = [ + { pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" }, + { pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" }, + { pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" }, + ] + const triage = [ + { + pr: 4, + url: "https://github.com/Kilo-Org/cloud/pull/4", + docs_worthy: false, + pending: false, + reason: "chore only", + }, + { + pr: 5, + url: "https://github.com/Kilo-Org/cloud/pull/5", + docs_worthy: false, + pending: true, + reason: "triage failed to classify this PR", + }, + ] + const worthy = [ + { number: 1, url: summary[0].url }, + { number: 2, url: summary[1].url }, + { number: 3, url: summary[2].url }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered }) + + // pending appears in neither Changes nor Considered + const changesText = changesRows.join("\n") + const skippedText = skippedRows.join("\n") + assert.ok(changesText.includes("pull/1"), "success in Changes") + assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes") + assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes") + assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered") + assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered") + assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered") + assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered") + assert.ok(pendingRows.some((r) => r.includes("pull/3"))) + assert.ok(pendingRows.some((r) => r.includes("pull/5"))) + + // round-trip renderBody → extractSectionRows + const through = "2026-07-20T09:59:59.999Z" + const body = renderBody({ + date: "2026-07-27", + since: "2026-07-17T00:00:00.000Z", + through, + changesRows, + pendingRows, + skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(body.includes(``)) + const extChanges = extractSectionRows(body, "changes") + const extPending = extractSectionRows(body, "pending") + const extSkipped = extractSectionRows(body, "skipped") + assert.deepEqual(extChanges, changesRows) + assert.deepEqual(extPending, pendingRows) + assert.deepEqual(extSkipped, skippedRows) + + // clean() prevents marker forgery in agent-generated row strings + { + const forgedRows = routeRows({ + summary: [ + { + pr: 9, + url: "https://github.com/Kilo-Org/cloud/pull/9", + action: "skipped", + reason: "x injection", + }, + ], + triage: [], + uncovered: [], + }) + assert.ok( + !forgedRows.skippedRows[0].includes(""), + "clean() must strip --> from reasons", + ) + const forgedBody = renderBody({ + date: "2026-07-27", + since: "s", + through: "t", + changesRows: [], + pendingRows: [], + skippedRows: forgedRows.skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + // Exactly one real section end marker — the forged sequences were stripped + assert.equal((forgedBody.match(//g) || []).length, 1) + const extracted = extractSectionRows(forgedBody, "skipped") + assert.equal(extracted.length, 1) + assert.ok(extracted[0].includes("injection")) + } + + const legacyRows = [ + "| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |", + "| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |", + "| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |", + "| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |", + ] + const kept = dropLegacySkipped(legacyRows) + assert.equal(kept.length, 1) + assert.ok(kept[0].includes("pull/4")) + assert.ok(!kept.some((r) => r.includes("edit pass failed"))) + assert.ok(!kept.some((r) => r.includes("triage failed to classify"))) + assert.ok(!kept.some((r) => r.includes("not classified by triage"))) +} + +// --------------------------------------------------------------------------- +// Case 5 — re-collection window +// --------------------------------------------------------------------------- +function case5_recollection() { + console.log("case 5: re-collection closes the loop") + + const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8") + // Query template must use merged:>= + assert.ok( + /merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc), + "collect.mjs must search merged:>=since", + ) + assert.match(collectSrc, /merged:>=/) + + const mergedAt = "2026-07-22T15:30:00.000Z" + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }] + const digest = [{ url: uncovered[0].url, merged_at: mergedAt }] + const now = "2026-07-27T12:00:00.000Z" + const since = computeProcessedThrough({ uncovered, digest, now }) + // held-back since is strictly before the uncovered PR's merged_at + assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`) + // And the query window merged:>=since therefore includes that PR + assert.ok(Date.parse(mergedAt) >= Date.parse(since)) +} + +// --------------------------------------------------------------------------- +// Case 6 — budgets +// --------------------------------------------------------------------------- +function case6_budgets() { + console.log("case 6: budgets") + + // --- edit budget --- + { + // 12 PRs = 3 batches of 5; budget too small for even one batch unit + const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: [], + priority: "medium", + })) + const cwd = setupEditCwd(worthy, triage) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + + // EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50). + // BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately. + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "0.0001", + EDIT_BATCH_TIMEOUT_MINUTES: "15", + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.ok(summary.every((e) => e.action === "pending")) + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 12) + assert.ok(summary.every((e) => e.action !== "skipped")) + } + + // --- triage budget --- + { + // CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk + const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i)) + const cwd = setupTriageCwd(digest) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "0.0001", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`) + + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 30) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(triage.every((e) => e.docs_worthy === false)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 30) + } +} + +// --------------------------------------------------------------------------- +// Case 7 — applyCap both arms +// --------------------------------------------------------------------------- +function case7_cap() { + console.log("case 7: applyCap") + + const now = new Date("2026-07-27T12:00:00.000Z") + const old = new Date("2026-06-01T00:00:00.000Z") + + const prevLog = console.log + const prevWarn = console.warn + const logs = [] + const warnings = [] + console.log = (...a) => logs.push(a.join(" ")) + console.warn = (...a) => warnings.push(a.join(" ")) + try { + // explicit:false + older than 14 days → clamped AND reported + const a = applyCap(old, now, { explicit: false }) + assert.equal(a.clamped, true) + assert.ok(a.since.getTime() > old.getTime()) + const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000) + assert.equal(a.since.toISOString(), cap.toISOString()) + assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped"))) + + // explicit:true + older than 14 days → unchanged, skip reported + logs.length = 0 + warnings.length = 0 + const b = applyCap(old, now, { explicit: true }) + assert.equal(b.clamped, false) + assert.equal(b.since.toISOString(), old.toISOString()) + assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l))) + } finally { + console.log = prevLog + console.warn = prevWarn + } +} + +// --------------------------------------------------------------------------- +// Case 8 — triage.mjs outputs +// --------------------------------------------------------------------------- +function case8_triage() { + console.log("case 8: triage pass outputs") + + // 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present + { + const digest = [samplePr(301), samplePr(302), samplePr(303)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 3) + assert.ok(triage.every((e) => e.pending === true)) + const summary = fs.readFileSync(summaryFile, "utf8") + assert.match(summary, /triage pending/) + for (const d of digest) { + assert.ok(summary.includes(d.url), `summary lists ${d.url}`) + } + assert.match(result.output, /::warning::.*since-override/) + } + + // 8a Run B: SINCE_OVERRIDE unset → warning absent + { + const digest = [samplePr(311), samplePr(312)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending")) + assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset") + } + + // 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT + { + const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "mixed-triage" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + const worthy = triage.filter((e) => e.docs_worthy === true).length + const pending = triage.filter((e) => e.pending === true).length + assert.ok(worthy > 0, "mixed stub must produce worthy > 0") + assert.ok(pending > 0, "mixed stub must leave some pending") + assert.ok( + !/::warning::.*since-override/.test(result.output), + "override warning must be ABSENT when worthy > 0 (Upsert will run)", + ) + } + + // 8b — partial classification → missing URLs pending:true + computeUncovered + { + // One chunk of 4 PRs; stub classifies first 3 only + const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "partial-triage" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 4) + const missing = triage.filter((e) => e.reason === "not classified by triage") + assert.ok(missing.length >= 1, "backfill must mark unclassified URLs") + assert.ok(missing.every((e) => e.pending === true)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + for (const m of missing) { + assert.ok( + uncovered.some((u) => u.url === m.url), + `${m.url} must appear in computeUncovered`, + ) + } + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const cases = [ + case1_mergeOrFallback, + case2_defectB, + case3_watermark, + case4_routing, + case5_recollection, + case6_budgets, + case7_cap, + case8_triage, + ] + let failed = 0 + for (const fn of cases) { + try { + fn() + console.log(` ok: ${fn.name}`) + } catch (err) { + failed++ + console.error(` FAIL: ${fn.name}`) + console.error(err) + } finally { + cleanup() + } + } + if (failed > 0) { + console.error(`\nselftest: ${failed} case(s) failed`) + process.exit(1) + } + console.log("\nselftest: all cases passed") +} + +main() diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index a3a5683116..eb075980b5 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -6,52 +6,82 @@ * A daily window holds ~30-50 PRs; a replay can hold several hundred. A * single triage call over that volume truncates its JSON output, so the * digest is split into chunks of CHUNK_SIZE and each chunk is triaged with - * its own `kilo run` call. A chunk that fails twice is degraded to - * "unclassified" entries (docs_worthy=false) instead of failing the run — - * the PR body then shows those PRs as skipped, visible to reviewers. + * its own `kilo run` call. A chunk that fails, is only partially classified, + * or is deferred by the wall-clock budget is marked pending:true (still + * docs_worthy:false so filter-worthy excludes it) so the watermark holds + * back and the next run re-collects those PRs. * * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script. + * Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" +import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const CHUNK_SIZE = 25 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" +const CHUNK_TIMEOUT_MS = 10 * 60 * 1000 const HERE = path.dirname(fileURLToPath(import.meta.url)) const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") const model = process.env.TRIAGE_MODEL if (!model) throw new Error("TRIAGE_MODEL is required") +const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35 + const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8")) -function triageChunk(chunk, index) { +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function pendingEntry(d, reason) { + return { + pr: d.number, + url: d.url, + docs_worthy: false, + pending: true, + reason, + target_sections: [], + priority: "medium", + } +} + +function triageChunk(chunk, index, budgetDeadline) { const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json` fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2)) + let lastCause = "triage failed to classify this PR" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - let raw - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - raw = execFileSync( - "kilo", - ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}` + console.warn( + `chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`, ) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) - continue + break } - fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) - const entries = parseTriageEntries(raw) + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), + streamStdout: false, + label: `triage chunk ${index} attempt ${attempt}`, + }) + + const raw = result.stdout + if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) + + const entries = raw ? parseTriageEntries(raw) : null if (entries) { // An entry for a PR outside this chunk must not win the shared dedupe // against the chunk that actually owns it — drop foreign entries. @@ -62,18 +92,35 @@ function triageChunk(chunk, index) { } if (owned.length > 0) return owned } - console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`) + + // Exit 0 is not success: unparseable output is a failure logged WITH + // the captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `triage chunk ${index}: ${cause}` + console.warn( + `chunk ${index} attempt ${attempt}: no valid JSON in output` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) { + console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) + } + } } - console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`) - return chunk.map((d) => ({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "triage failed to classify this PR", - target_sections: [], - priority: "medium", - })) + console.warn( + `::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`, + ) + return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`)) } const chunks = [] @@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) { } console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`) +const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES) const merged = [] const seen = new Set() + for (let i = 0; i < chunks.length; i++) { - for (const e of triageChunk(chunks[i], i)) { + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0) + console.warn( + `stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`, + ) + const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < chunks.length; j++) { + for (const d of chunks[j]) { + if (seen.has(d.url)) continue + seen.add(d.url) + merged.push(pendingEntry(d, cause)) + } + } + break + } + + for (const e of triageChunk(chunks[i], i, budgetDeadline)) { if (seen.has(e.url)) continue seen.add(e.url) merged.push(e) } } -// Coverage: every digest PR gets a triage entry so the PR body's skipped -// table is complete. Unclassified defaults to not-docs-worthy (conservative). +// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and +// any other missing URL are pending:true — not a genuine "not worthy" verdict. for (const d of digest) { if (seen.has(d.url)) continue - merged.push({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "not classified by triage", - target_sections: [], - priority: "medium", - }) + merged.push(pendingEntry(d, "not classified by triage")) } fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2)) const worthy = merged.filter((e) => e.docs_worthy).length -console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`) +const pending = merged.filter((e) => e.pending === true) +console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`) + +// Upsert is gated off when worthy == 0, so triage emits its own Step Summary +// listing every PR it marked pending:true and why. +if (pending.length > 0) { + const lines = pending.map((e) => `- [${e.url}] ${e.reason}`) + appendSummary( + `### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`, + ) +} + +// Replay warning (S2j): warn IFF since-override AND something pending AND +// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs). +const sinceOverride = process.env.SINCE_OVERRIDE === "true" +if (sinceOverride && pending.length > 0 && worthy === 0) { + console.warn( + "::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body", + ) +} diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index e39551f307..1ef25b7596 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -8,6 +8,14 @@ * diff exceeds the file cap or verification failed. The PR body carries * marker-delimited sections so later runs can append rows, plus a * machine-readable processed-through watermark. + * + * Watermark invariant: processed-through never moves past a PR that has no + * terminal outcome. Terminal := action !== "pending" (a deliberate agent + * "skipped" IS terminal). Uncovered PRs hold the marker at earliest + * merged_at − 1 ms so collect's merged:>=since re-collects them next run. + * Three review rounds found four independent defects in a queue-based + * alternative (unreachable gate, empty-PR creation, cap-overflow loss, + * draft-state corruption); a held-back watermark has none of those modes. */ import { execFileSync } from "node:child_process" @@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url" const BRANCH = process.env.BRANCH || "docs/auto-sync" const FILE_CAP = 15 const ROW_CAP = 150 +const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" const DOCS_PATH = "packages/kilo-docs" @@ -42,6 +51,11 @@ function skippedRow(e) { return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` } +function pendingRow(e) { + const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` +} + export function extractSectionRows(body, name) { const m = String(body ?? "").match( new RegExp(`([\\s\\S]*?)`), @@ -50,7 +64,14 @@ export function extractSectionRows(body, name) { return m[1] .split("\n") .map((l) => l.trim()) - .filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l)) + .filter( + (l) => + l.startsWith("|") && + !l.startsWith("| ---") && + !/^\|\s*Docs change/.test(l) && + !/^\|\s*PR\s*\|/.test(l) && + !/^\|\s*Why\s*\|/.test(l), + ) } function section(name, header, rows) { @@ -58,7 +79,12 @@ function section(name, header, rows) { return `\n${body}\n` } -export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) { +export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) { + const pendingDisplay = + pendingRows.length > PENDING_DISPLAY_CAP + ? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`] + : pendingRows + return `## Automated docs sync — ${date} This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents. @@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf ${section("changes", "| Docs change | Source |", changesRows)} +### Pending — will retry + +${section("pending", "| PR | Why |", pendingDisplay)} + ### Considered, no docs change needed ${section("skipped", "| PR | Reason |", skippedRows)} @@ -100,32 +130,222 @@ function readJson(path, fallback) { } } +/** + * Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending") + * ∪ (triage entries with pending: true). A worthy PR is covered iff it has a + * summary row whose action !== "pending" and carries no triage pending flag. + */ +export function computeUncovered({ worthy, summary, triage }) { + const worthyList = Array.isArray(worthy) ? worthy : [] + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + + const summaryByUrl = new Map() + for (const e of summaryList) { + if (e?.url) summaryByUrl.set(e.url, e) + } + + const triagePendingByUrl = new Map() + for (const e of triageList) { + if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e) + } + + /** @type {Map} */ + const out = new Map() + + for (const w of worthyList) { + const url = w?.url + if (!url) continue + const row = summaryByUrl.get(url) + if (!row) { + out.set(url, { + url, + pr: w.number ?? w.pr, + reason: "no edit summary row (edit pass did not cover this PR)", + }) + continue + } + if (row.action === "pending") { + out.set(url, { + url, + pr: row.pr ?? w.number ?? w.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + // Summary pending rows for URLs not in worthy (defensive). + for (const row of summaryList) { + if (row?.action === "pending" && row.url && !out.has(row.url)) { + out.set(row.url, { + url: row.url, + pr: row.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + for (const [url, e] of triagePendingByUrl) { + if (out.has(url)) continue + out.set(url, { + url, + pr: e.pr, + reason: e.reason || "triage pending", + }) + } + + return [...out.values()] +} + +/** + * processed-through = now when uncovered is empty; otherwise earliest + * merged_at among uncovered PRs minus 1 ms (from digest-full.json). + * When uncovered is non-empty but no merged_at resolves, hold at + * `fallback` (the run's window start / SINCE): every uncovered PR was + * collected via merged:>=since, so holding there re-collects all of them. + * Never advance past unresolved uncovered PRs (Defect-B permanent-loss). + */ +export function computeProcessedThrough({ uncovered, digest, now, fallback }) { + const nowIso = typeof now === "string" ? now : new Date(now).toISOString() + if (!uncovered || uncovered.length === 0) return nowIso + + const digestList = Array.isArray(digest) ? digest : [] + const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d])) + + let earliest = null + for (const u of uncovered) { + const d = byUrl.get(u.url) + const mergedAt = d?.merged_at + if (!mergedAt) continue + const t = Date.parse(mergedAt) + if (!Number.isFinite(t)) continue + if (earliest === null || t < earliest) earliest = t + } + + if (earliest === null) { + // digest-full missing/corrupt while uncovered is non-empty: hold at + // window start so collect's merged:>=since re-collects every PR. + // Never use now−1ms — that strands uncovered PRs permanently. + const fallbackMs = fallback == null ? NaN : Date.parse(fallback) + if (!Number.isFinite(fallbackMs)) { + throw new Error( + `docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`, + ) + } + const fallbackIso = new Date(fallbackMs).toISOString() + console.warn( + `::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`, + ) + return fallbackIso + } + + return new Date(earliest - 1).toISOString() +} + +/** + * Route summary + triage into the three body sections. + * changesRows = action neither skipped nor pending + * pendingRows = uncovered from computeUncovered + * skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending + */ +export function routeRows({ summary, triage, uncovered }) { + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + const uncoveredList = Array.isArray(uncovered) ? uncovered : [] + + const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending") + const skippedEntries = [ + ...triageList.filter((e) => e.docs_worthy === false && e.pending !== true), + ...summaryList.filter((e) => e.action === "skipped"), + ] + + return { + changesRows: changesEntries.map(changeRow), + pendingRows: uncoveredList.map(pendingRow), + skippedRows: skippedEntries.map(skippedRow), + } +} + +/** + * Drop pre-existing Considered rows whose reason contains any of the three + * legacy failure literals (substring match — live rows carry longer strings). + * Genuine no-doc-needed rows are untouched. + */ +export function dropLegacySkipped(rows) { + const list = Array.isArray(rows) ? rows : [] + const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"] + return list.filter((row) => { + const s = String(row ?? "") + return !needles.some((n) => s.includes(n)) + }) +} + +/** + * No-diff early-return report. Returns summary markdown and an optional + * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit + * happened — that is the caller's situation). + */ +export function noDiffReport({ uncovered, sinceOverride }) { + const list = Array.isArray(uncovered) ? uncovered : [] + const lines = + list.length === 0 + ? ["The agent found nothing worth documenting in this window."] + : [ + `No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`, + "", + ...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`), + ] + + const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}` + + let warning = null + if (sinceOverride && list.length > 0) { + warning = + "docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body" + } + + return { summary, warning } +} + async function main() { const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") - const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString() + const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" + const sinceOverride = process.env.SINCE_OVERRIDE === "true" const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh" const existingPr = process.env.PR_NUMBER || "" const verified = process.env.VERIFIED === "true" - const date = through.slice(0, 10) + const date = now.slice(0, 10) // The agent's run summary is consumed here and never committed. const agentSummary = readJson(SUMMARY_FILE, []) fs.rmSync(SUMMARY_FILE, { force: true }) const triage = readJson("docs-sync-out/triage.json", []) + const worthy = readJson("docs-sync-out/worthy.json", []) + const digest = readJson("docs-sync-out/digest-full.json", []) + + // Order matters: compute uncovered BEFORE the no-diff early return so + // noDiffReport can name every held-back PR. + const uncovered = computeUncovered({ worthy, summary: agentSummary, triage }) if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") { console.log("no packages/kilo-docs changes produced; nothing to commit") - appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.") + const { summary, warning } = noDiffReport({ uncovered, sinceOverride }) + appendSummary(summary) + if (warning) console.warn(`::warning::${warning}`) return } - git(["config", "user.name", "github-actions[bot]"]) - git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) + // Git identity is configured once in docs-sync.yml (Configure git identity) + // before any commit-creating step, including prepare-branch's merge. git(["add", DOCS_PATH]) git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + // Watermark: now when fully covered; else earliest uncovered merged_at − 1ms. + // Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs. + const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) + // The draft cap bounds the cumulative PR diff, not just this run's commit. const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]) .split("\n") @@ -151,25 +371,33 @@ async function main() { git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) - const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow) - const skippedNew = [ - ...triage.filter((e) => e.docs_worthy === false), - ...agentSummary.filter((e) => e.action === "skipped"), - ].map(skippedRow) + const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({ + summary: agentSummary, + triage, + uncovered, + }) let oldChanges = [] let oldSkipped = [] + let oldPending = [] if (mode === "update" && existingPr) { const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) oldChanges = extractSectionRows(pr.body, "changes") - oldSkipped = extractSectionRows(pr.body, "skipped") + oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) + oldPending = extractSectionRows(pr.body, "pending") } + // Pending is replaced each run (informational only); do not merge legacy + // pending rows — uncovered is recomputed fresh. oldPending is read only so + // extractSectionRows stays exercised; discarded deliberately. + void oldPending + const body = renderBody({ date, since, through, changesRows: mergeRows(oldChanges, changesNew), + pendingRows: pendingNew, skippedRows: mergeRows(oldSkipped, skippedNew), verified, draftReasons, @@ -228,8 +456,10 @@ async function main() { } appendOutput("pr_url", prUrl) - appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`) - console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`) + appendSummary( + `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, + ) + console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index cc4c969016..32986fef67 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -7,9 +7,10 @@ * * Priority: workflow_dispatch input `since` > latest open bot PR marker > * last merged bot PR marker > 72h ago. Hard cap: never look back more than - * 14 days. + * 14 days — unless the human explicitly requested a window via INPUT_SINCE. */ +import { pathToFileURL } from "node:url" import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs" const FALLBACK_HOURS = 72 @@ -42,34 +43,65 @@ async function findWatermark() { return null } -const now = new Date() -let since - -const input = (process.env.INPUT_SINCE ?? "").trim() -if (input) { - since = new Date(input) - if (Number.isNaN(since.getTime())) { - throw new Error(`Invalid INPUT_SINCE: ${input}`) +/** + * Apply the 14-day lookback cap. When `explicit` is true (dispatch override), + * the cap is skipped so a human-requested recovery window is not silently + * shortened. Returns `{ since, clamped }`. + */ +export function applyCap(since, now, { explicit = false } = {}) { + if (explicit) { + console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`) + return { since, clamped: false } } - console.log(`watermark from dispatch input: ${since.toISOString()}`) -} else { - since = - (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) + if (since < cap) { + const from = since.toISOString() + const to = cap.toISOString() + console.warn( + `::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` + + `Anything still uncovered before ${to} is abandoned and needs a human.`, + ) + return { since: cap, clamped: true } + } + return { since, clamped: false } } -// A forged, edited, or malformed marker in the future would silently match -// nothing in the merged:>= search; clamp it loudly. -if (since > now) { - console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) - since = now +async function main() { + const now = new Date() + let since + let explicit = false + + const input = (process.env.INPUT_SINCE ?? "").trim() + if (input) { + since = new Date(input) + if (Number.isNaN(since.getTime())) { + throw new Error(`Invalid INPUT_SINCE: ${input}`) + } + explicit = true + console.log(`watermark from dispatch input: ${since.toISOString()}`) + } else { + since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + } + + // A forged, edited, or malformed marker in the future would silently match + // nothing in the merged:>= search; clamp it loudly. + if (since > now) { + console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) + since = now + } + + ;({ since } = applyCap(since, now, { explicit })) + + appendOutput("since", since.toISOString()) + appendOutput("now", now.toISOString()) + appendOutput("since_override", explicit ? "true" : "false") + appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) } -const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) -if (since < cap) { - console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`) - since = cap +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) } - -appendOutput("since", since.toISOString()) -appendOutput("now", now.toISOString()) -appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index ffc803c17c..5a32e87698 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -5,9 +5,13 @@ name: docs-sync # triages them for docs relevance, runs Kilo CLI headless to update # packages/kilo-docs, and maintains one rolling PR for human review. # -# Security posture: scheduled/manual only, checks out main, never executes -# code from PR branches. State is derived from the bot's own PRs (watermark -# marker in the PR body), so missed or failed runs self-heal on the next run. +# Security posture: scheduled/manual runs check out the dispatched ref and may +# push/comment with write permissions and org secrets. PR runs (paths-limited to +# this workflow and .github/docs-sync/**) execute branch code only in a +# read-only, secretless `selftest` job that never pushes, comments, or calls an +# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only. +# State is derived from the bot's own PRs (watermark marker in the PR body), so +# missed or failed runs self-heal on the next run. on: schedule: @@ -22,6 +26,10 @@ on: description: "Collect + triage only, no edits, no PR" type: boolean default: false + pull_request: + paths: + - ".github/docs-sync/**" + - ".github/workflows/docs-sync.yml" permissions: contents: write # push the rolling branch, create the auto-docs label @@ -29,7 +37,7 @@ permissions: issues: write # comment on the rolling PR concurrency: - group: docs-sync + group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }} cancel-in-progress: false env: @@ -37,9 +45,28 @@ env: EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} jobs: - sync: + selftest: if: github.repository == 'Kilo-Org/kilocode' runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + + sync: + if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' + runs-on: blacksmith-4vcpu-ubuntu-2404 + # Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve. timeout-minutes: 120 env: # Both are required: without KILO_ORG_ID the gateway bills the key @@ -52,12 +79,20 @@ jobs: with: fetch-depth: 0 # prepare-branch merges main into the rolling branch + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Setup Node uses: actions/setup-node@v6 with: node-version: "24" package-manager-cache: false + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + - name: Install Kilo CLI run: | npm install -g @kilocode/cli @@ -79,6 +114,8 @@ jobs: - name: Triage merged PRs (LLM, chunked) id: triage if: steps.collect.outputs.count != '0' + env: + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -104,8 +141,18 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/docs-sync/prepare-branch.mjs + # After prepare-branch checks out the rolling branch and merges main, the + # worktree holds main's scripts. Restore the dispatched ref's copies so a + # branch-dispatch AC9 run actually exercises the fixed code. git restore + # (not checkout) leaves them unstaged so upsert-pr's bare commit won't + # include them in the docs PR. + - name: Restore docs-sync scripts from the dispatched ref + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + run: git restore --source=${{ github.sha }} -- .github/docs-sync + - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + continue-on-error: true run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -122,6 +169,7 @@ jobs: id: fix if: steps.verify.outcome == 'failure' continue-on-error: true + timeout-minutes: 10 env: NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | @@ -150,6 +198,7 @@ jobs: GH_TOKEN: ${{ github.token }} PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} SINCE: ${{ steps.wm.outputs.since }} + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} BRANCH: ${{ steps.prep.outputs.branch }} PREP_MODE: ${{ steps.prep.outputs.mode }} PR_NUMBER: ${{ steps.prep.outputs.pr_number }}