Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9

This commit is contained in:
marius-kilocode
2026-07-28 10:43:36 +02:00
112 changed files with 4176 additions and 300 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix settings changes sometimes failing to save and apply in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---
Allow sending prompts while a session is busy and show queued prompts with a remove action.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stabilize cross-platform CLI subprocess tests under constrained CI runners
+5
View File
@@ -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
+107 -31
View File
@@ -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<string, string>} 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`)
+95
View File
@@ -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 }
}
+77 -33
View File
@@ -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)
})
}
+964
View File
@@ -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(`<!-- docs-sync: processed-through ${through} -->`))
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 <!-- docs-sync:skipped:end --> injection",
},
],
triage: [],
uncovered: [],
})
assert.ok(
!forgedRows.skippedRows[0].includes("<!--"),
"clean() must strip <!-- from reasons",
)
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(/<!--\s*docs-sync:skipped:end\s*-->/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()
+120 -42
View File
@@ -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",
)
}
+245 -15
View File
@@ -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*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\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 `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
}
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<string, { url: string, pr?: number, reason: string }>} */
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 now1ms — 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
+58 -26
View File
@@ -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`)
+54 -5
View File
@@ -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 }}
@@ -142,23 +142,43 @@ 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: |
./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 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: |
@@ -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.
{% /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.
+33
View File
@@ -102,6 +102,39 @@
## [Unreleased]
## [7.0.12-rc.2] - 2026-07-27
### Added
### Fixed
- Fix the GitHub-hosted bundled JetBrains plugin build so signing uses certificate and private-key files during verification.
### Changed
## [7.0.12-rc.1] - 2026-07-27
### Added
- 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
### Added
- Add a signed GitHub-hosted bundled JetBrains plugin build that includes the Kilo CLI for offline or restricted-network installs.
### Fixed
- 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
- 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
@@ -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")
@@ -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)
@@ -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)) }
@@ -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<SseEvent>(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)
}
}
@@ -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("""
@@ -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
+9
View File
@@ -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,8 +208,12 @@ intellijPlatform {
}
signing {
// 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")
}
@@ -220,6 +225,10 @@ intellijPlatform {
}
tasks {
named("verifyPluginSignature") {
dependsOn("signPlugin")
}
withType<InstrumentCodeTask> {
enabled = false
}
@@ -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) }
}
@@ -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,
@@ -464,6 +464,27 @@ class SessionController(
}
}
fun deleteQueuedMessage(message: String) {
assertEdt()
val id = sid ?: return
cs.launch {
try {
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)
}
}
}
fun unrevert() {
assertEdt()
val id = sid ?: return
@@ -1368,6 +1389,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 +1429,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 +1452,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 +2337,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
@@ -74,6 +74,9 @@ class SessionModel {
private var revert: SessionRevertDto? = null
var queued: Set<String> = 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<String>) {
if (queued == ids) return
queued = ids
fire(SessionModelEvent.QueueChanged(ids))
}
@RequiresEdt
fun setDiff(diff: List<DiffFileDto>) {
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()
@@ -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<String>) : SessionModelEvent() {
override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]"
}
data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() {
override fun toString() = "HeaderUpdated visible=${header.visible}"
}
@@ -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.
@@ -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,
@@ -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) + "<br>" +
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop))
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut))
)
}
@@ -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()
@@ -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<String, MessageView>()
@@ -71,6 +72,12 @@ class TurnView(
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)
@@ -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.
@@ -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=جلسة بدون عنوان
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=名前なしのセッション
@@ -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=제목 없는 세션
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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=Незаголовок сессия
@@ -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=เซสชันไม่มีชื่อ
@@ -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
@@ -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=Сесія без назви
@@ -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=无标题会话
@@ -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=未命名的工作階段
@@ -102,6 +102,46 @@ 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<String>(), 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)
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`() {
val (m, _, _) = prompted()
@@ -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`() {
@@ -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<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
assertTrue(components(u2).filterIsInstance<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
val remove = components(u2).filterIsInstance<HoverIcon>().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`() {
@@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
val aborts = mutableListOf<Pair<String, String>>()
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
val reverts = mutableListOf<RevertCall>()
val messageDeletes = mutableListOf<MessageDeleteCall>()
var messageDeleteResult = true
val unreverts = mutableListOf<Pair<String, String>>()
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
val permissionReplies = mutableListOf<Triple<String, String, PermissionReplyDto>>()
@@ -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()
+1 -1
View File
@@ -1,5 +1,5 @@
kotlin.stdlib.default.dependency=false
kilo.jetbrains.version=7.0.10
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.
@@ -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",
@@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
/** 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)
@@ -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<String> = emptyList(),
) : ChatEventDto()
@Serializable
@SerialName("session.compacted")
data class SessionCompacted(
@@ -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;
}
@@ -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)
})
})
+20 -4
View File
@@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
tool?: string
callID?: string
partID?: string
approvalPlacement?: "body" | "hidden"
}
type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen" | "defaultOpen">
@@ -19,26 +20,41 @@ 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 ? <ToolApprovalLine display={value} /> : null
}
}
/**
* 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()
const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, 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 = () => (
<div data-slot="basic-tool-details">
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
{props.children}
</div>
)
if (!("children" in props) && !approval()) {
if (!("children" in props) && !inBody()) {
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
}
return (
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
{details()}
</Base>
)
@@ -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) {
}}
</Match>
<Match when={true}>
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
<ToolApprovalProvider
value={() =>
resolveToolApproval(
meta(),
i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string,
)
}
>
<Dynamic
component={render()}
input={input()}
@@ -1497,9 +1504,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
/>
</Tooltip>
</Show>
<Show when={props.throughput}>
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
</Show>
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
</div>
</Show>
<Show when={summary()}>
@@ -2172,6 +2177,8 @@ ToolRegistry.register({
}, 50)
}
const approvalLine = useToolApprovalLine()
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
@@ -2190,11 +2197,22 @@ ToolRegistry.register({
</Match>
</Switch>
</Show>
{/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
{approvalLine()}
</div>
</div>
)
return <BasicTool hideDetails icon="task" status={props.status} trigger={trigger()} animated />
return (
<BasicTool
hideDetails
approvalPlacement="hidden"
icon="task"
status={props.status}
trigger={trigger()}
animated
/>
)
},
})
@@ -2932,6 +2950,7 @@ ToolRegistry.register({
<BasicTool
{...props}
defaultOpen
approvalPlacement="hidden"
icon="checklist"
trigger={
<ToolTriggerRow
@@ -0,0 +1,4 @@
export function changed<T>(current: T | undefined, next: T | undefined, key: (item: T) => string) {
if (current === undefined || next === undefined) return current !== next
return key(current) !== key(next)
}
@@ -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)
})
})
@@ -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<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
const key = (item: T) => (props.value ? props.value(item) : (item as string))
return (
<Base
{...props}
onSelect={(next) => {
if (!changed(props.current, next, key)) return
props.onSelect?.(next)
}}
/>
)
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { resolveToolApproval } from "./tool-approval"
// Echo the key + params so assertions can see which string was chosen without a real dictionary.
const t = (key: string, params?: Record<string, string | number | boolean>) =>
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()
})
})
@@ -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 }) {
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
<span data-slot="tool-approval-decision">{props.display.decision}</span>
<Show when={!manual()}>
<Show when={props.display.source}>
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
</Show>
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
</Show>
</div>
+2 -2
View File
@@ -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",
+14
View File
@@ -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<typeof handleEditorAction>[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)
+3 -1
View File
@@ -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.
@@ -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"
@@ -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 }),
]),
)
})
})
// ---------------------------------------------------------------------------
@@ -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")
@@ -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[] = []) => {
+9 -8
View File
@@ -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 }) => {
@@ -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>): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const name = messages[i]?.agent?.trim()
@@ -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()
})
@@ -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
@@ -0,0 +1,51 @@
import path from "path"
import fs from "fs/promises"
import { createRequire } from "module"
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 })
// 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 })
// 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 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")
}
}
@@ -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",
+117 -14
View File
@@ -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<typeof Bun.spawn>
// ---------------------------------------------------------------------------
// 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,74 @@ 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<Uint8Array>) {
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<number, number[]>()
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") 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)
}
}
}
}
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 +308,36 @@ async function run(file: string): Promise<Result> {
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 +356,7 @@ function finish(proc: ReturnType<typeof Bun.spawn>) {
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 +371,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 +551,8 @@ if (ci) {
})
}
await cleanBinary()
process.exit(failures.length > 0 ? 1 : 0)
// ---------------------------------------------------------------------------
+2
View File
@@ -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 {
+3
View File
@@ -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.
@@ -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<void>
dispose: () => Promise<void>
stopServer: () => Promise<void>
}) {
return async () => {
await input.drain()
await input.dispose()
await input.stopServer()
}
}
+12 -2
View File
@@ -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<ReturnType<typeof Server.listen>> | 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.
@@ -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<void>, onError?: (err: unknown) => void) {
let done: Promise<void> | undefined
return () => {
if (!done) {
done = run().catch((err) => {
onError?.(err)
})
}
return done
}
}
}
@@ -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<string, { timeout: Timer; due: number; data: Map<string, Data> }>()
// 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<string, { count: number; until: number }>()
// 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<Promise<void>>()
// 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<string, Share>()
// 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<string, Data>) {
function schedule(sessionId: string, due: number, data: Map<string, Data>, 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<void>((resolve) => {
timer = set(() => resolve(), left)
})
await Promise.race([Promise.allSettled(pending), timeout])
if (timer !== undefined) clear(timer)
}
}
return {
sync,
flush,
drain,
} as const
}
}
@@ -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<void> | undefined
@@ -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.
@@ -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] : []
}
+7 -5
View File
@@ -616,15 +616,17 @@ 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)
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 - treat opus-4.8 and fable like opus-4.7
// kilocode_change start - include Claude 5+ models
if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) {
return ["low", "medium", "high", "xhigh", "max"]
}
@@ -640,7 +642,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) {
@@ -976,7 +978,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
// kilocode_change start - include Claude 5+ models
if (
model.api.id.includes("opus-4.7") ||
model.api.id.includes("opus-4.8") ||
+9
View File
@@ -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
})
@@ -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<number | undefined> = []
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)
})
})
@@ -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<void>()
const stopped = Promise.withResolvers<void>()
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")

Some files were not shown because too many files have changed in this diff Show More