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