mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.17.9
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings changes sometimes failing to save and apply in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Allow sending prompts while a session is busy and show queued prompts with a remove action.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Stabilize cross-platform CLI subprocess tests under constrained CI runners
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
|
||||
+107
-31
@@ -6,20 +6,22 @@
|
||||
* Batching bounds each `kilo run` context (a replay window can yield dozens
|
||||
* of docs-worthy PRs with large diffs). Each batch gets its own CLI session
|
||||
* and writes its own summary file; results are merged into
|
||||
* docs-sync-out/edit-summary.json. A batch that fails is skipped with a
|
||||
* warning — its PRs show up in the rolling PR body as skipped, so nothing
|
||||
* fails silently.
|
||||
* docs-sync-out/edit-summary.json. A batch that fails or is deferred by the
|
||||
* wall-clock budget is recorded as action "pending" so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider).
|
||||
* Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15).
|
||||
* Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const BATCH_SIZE = 5
|
||||
const ATTEMPTS = 2
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
export const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
|
||||
@@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8")
|
||||
const model = process.env.EDIT_MODEL
|
||||
if (!model) throw new Error("EDIT_MODEL is required")
|
||||
|
||||
const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50
|
||||
const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15
|
||||
const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000
|
||||
|
||||
const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8"))
|
||||
const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8"))
|
||||
const priority = new Map(triage.map((e) => [e.url, e]))
|
||||
@@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => {
|
||||
return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1)
|
||||
})
|
||||
|
||||
function editBatch(batch, index) {
|
||||
/** @type {Map<string, string>} url → pending cause for failed/deferred batches */
|
||||
const pendingCauses = new Map()
|
||||
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function editBatch(batch, index, budgetDeadline) {
|
||||
const batchFile = `${OUT_DIR}/edit-batch-${index}.json`
|
||||
const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json`
|
||||
const summaryFile = `${OUT_DIR}/edit-summary-${index}.json`
|
||||
@@ -54,31 +71,59 @@ function editBatch(batch, index) {
|
||||
|
||||
Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).`
|
||||
|
||||
let lastCause = "edit pass failed"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// Message positional first: --file is multi-value and would otherwise
|
||||
// consume a trailing message as a file path ("File not found").
|
||||
execFileSync(
|
||||
"kilo",
|
||||
["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
// stdout streams live to the Actions log; stderr is piped so failure
|
||||
// warnings can include the tail of the actual CLI error.
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] },
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)`
|
||||
console.warn(
|
||||
`batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`,
|
||||
)
|
||||
if (fs.existsSync(summaryFile)) return true
|
||||
// Tolerate the agent dropping the docs-sync-out/ prefix.
|
||||
const alt = path.basename(summaryFile)
|
||||
if (fs.existsSync(alt)) {
|
||||
fs.renameSync(alt, summaryFile)
|
||||
return true
|
||||
break
|
||||
}
|
||||
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile],
|
||||
timeoutMs: Math.min(BATCH_TIMEOUT_MS, left),
|
||||
streamStdout: true,
|
||||
label: `edit batch ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
if (fs.existsSync(summaryFile)) return true
|
||||
// Tolerate the agent dropping the docs-sync-out/ prefix.
|
||||
const alt = path.basename(summaryFile)
|
||||
if (fs.existsSync(alt)) {
|
||||
fs.renameSync(alt, summaryFile)
|
||||
return true
|
||||
}
|
||||
|
||||
// Exit 0 is not success: missing summary is a failure logged WITH the
|
||||
// captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `edit batch ${index}: ${cause}`
|
||||
console.warn(
|
||||
`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
// Skip the wait when the remaining budget cannot fit another attempt.
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) {
|
||||
console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`)
|
||||
} catch (err) {
|
||||
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
|
||||
console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
|
||||
}
|
||||
}
|
||||
console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`)
|
||||
|
||||
console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`)
|
||||
for (const d of batch) pendingCauses.set(d.url, lastCause)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) {
|
||||
}
|
||||
console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(EDIT_BUDGET_MINUTES)
|
||||
let deferredFrom = -1
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
editBatch(batches[i], i)
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < BATCH_TIMEOUT_MS) {
|
||||
deferredFrom = i
|
||||
const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0)
|
||||
console.warn(
|
||||
`stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < batches.length; j++) {
|
||||
for (const d of batches[j]) pendingCauses.set(d.url, cause)
|
||||
}
|
||||
break
|
||||
}
|
||||
editBatch(batches[i], i, budgetDeadline)
|
||||
}
|
||||
|
||||
if (deferredFrom >= 0) {
|
||||
console.warn(
|
||||
`edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`,
|
||||
)
|
||||
}
|
||||
|
||||
// Merge batch summaries. Coverage: every worthy PR gets an entry so the PR
|
||||
// body accounts for it; failed batches show up as skipped.
|
||||
// body accounts for it; failed/deferred batches show up as pending (not skipped).
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
@@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) {
|
||||
const url = String(e?.url ?? "")
|
||||
if (!url.startsWith("http") || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") })
|
||||
merged.push({
|
||||
pr: Number(e.pr) || 0,
|
||||
url,
|
||||
action: String(e.action ?? "skipped"),
|
||||
reason: String(e.reason ?? ""),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const d of ordered) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" })
|
||||
const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR"
|
||||
merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause })
|
||||
}
|
||||
|
||||
// upsert-pr.mjs consumes the merged summary from the repo root; the file is
|
||||
// removed there before committing so it never lands in the docs PR.
|
||||
fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2))
|
||||
console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`)
|
||||
const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length
|
||||
const skipped = merged.filter((e) => e.action === "skipped").length
|
||||
const pending = merged.filter((e) => e.action === "pending").length
|
||||
console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,964 @@
|
||||
// kilocode_change - new file
|
||||
|
||||
/**
|
||||
* Offline self-check for the docs-sync failure paths (S4).
|
||||
* Plain node:assert, no network, no LLM, no new dependency.
|
||||
* Run: node .github/docs-sync/selftest.mjs
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict"
|
||||
import { execFileSync, spawnSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs"
|
||||
import { applyCap } from "./watermark.mjs"
|
||||
import {
|
||||
computeUncovered,
|
||||
computeProcessedThrough,
|
||||
routeRows,
|
||||
dropLegacySkipped,
|
||||
noDiffReport,
|
||||
renderBody,
|
||||
extractSectionRows,
|
||||
} from "./upsert-pr.mjs"
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const EDIT_SCRIPT = path.join(HERE, "edit.mjs")
|
||||
const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs")
|
||||
const COLLECT_SCRIPT = path.join(HERE, "collect.mjs")
|
||||
|
||||
const temps = []
|
||||
|
||||
function mktemp(prefix) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
||||
temps.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
for (const dir of temps.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeExecutable(filePath, body) {
|
||||
fs.writeFileSync(filePath, body, { mode: 0o755 })
|
||||
}
|
||||
|
||||
function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) {
|
||||
const dir = mktemp("docs-sync-kilo-")
|
||||
const kiloPath = path.join(dir, "kilo")
|
||||
// mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage"
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const mode = ${JSON.stringify(mode)};
|
||||
const callLog = ${JSON.stringify(callLog ?? "")};
|
||||
const stderrText = ${JSON.stringify(stderrText)};
|
||||
if (callLog) {
|
||||
fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n");
|
||||
}
|
||||
if (mode === "stderr-exit0") {
|
||||
process.stderr.write(stderrText + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "record") {
|
||||
process.stderr.write("recorded\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
// Parse -f chunk/batch file from args for triage stubs
|
||||
const args = process.argv.slice(2);
|
||||
const fIdx = args.indexOf("-f");
|
||||
const fileArg = fIdx >= 0 ? args[fIdx + 1] : null;
|
||||
let chunk = [];
|
||||
if (fileArg && fs.existsSync(fileArg)) {
|
||||
try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; }
|
||||
}
|
||||
if (mode === "partial-triage") {
|
||||
// Classify only a proper subset (first URL) of the chunk.
|
||||
const owned = chunk.slice(0, Math.max(0, chunk.length - 1));
|
||||
const entries = owned.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "genuine not worthy",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}));
|
||||
if (entries.length === 0 && chunk.length > 0) {
|
||||
// single-PR chunk: still leave one missing by emitting empty-ish foreign-only
|
||||
process.stdout.write("[]\\n");
|
||||
} else {
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
if (mode === "mixed-triage") {
|
||||
// Half docs_worthy true, half fail (no output for second half — but we return
|
||||
// only some entries so backfill marks the rest pending). Actually: return
|
||||
// docs_worthy:true for first half of chunk URLs so worthy > 0.
|
||||
const half = Math.ceil(chunk.length / 2);
|
||||
const entries = chunk.slice(0, half).map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}));
|
||||
process.stdout.write(JSON.stringify(entries) + "\\n");
|
||||
process.exit(0);
|
||||
}
|
||||
process.stderr.write("unknown stub mode\\n");
|
||||
process.exit(1);
|
||||
`
|
||||
writeExecutable(kiloPath, script)
|
||||
return dir
|
||||
}
|
||||
|
||||
function gitIn(cwd, args, env = {}) {
|
||||
return execFileSync("git", args, {
|
||||
cwd,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
}
|
||||
|
||||
function makeGitRunner(cwd, env = {}) {
|
||||
return (args) => gitIn(cwd, args, env)
|
||||
}
|
||||
|
||||
function initRepoWithIdentity(dir) {
|
||||
gitIn(dir, ["init", "-b", "main"])
|
||||
gitIn(dir, ["config", "user.name", "docs-sync-selftest"])
|
||||
gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"])
|
||||
gitIn(dir, ["config", "commit.gpgsign", "false"])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 1 — Defect A: mergeOrFallback
|
||||
// ---------------------------------------------------------------------------
|
||||
function case1_mergeOrFallback() {
|
||||
console.log("case 1: Defect A (mergeOrFallback)")
|
||||
|
||||
// 1a — identity configured + clean merge → mode=update
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-clean-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
|
||||
gitIn(dir, ["add", "a.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n")
|
||||
gitIn(dir, ["add", "b.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch commit"])
|
||||
// Advance main without conflict
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "c.txt"), "on main\n")
|
||||
gitIn(dir, ["add", "c.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main advance"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
|
||||
assert.equal(result.mode, "update")
|
||||
assert.equal(result.branch, DEFAULT_BRANCH)
|
||||
// merge brought c.txt in
|
||||
assert.ok(fs.existsSync(path.join(dir, "c.txt")))
|
||||
}
|
||||
|
||||
// 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-conflict-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
const baseSha = gitIn(dir, ["rev-parse", "HEAD"])
|
||||
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch edit"])
|
||||
const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"])
|
||||
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n")
|
||||
gitIn(dir, ["add", "conflict.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main edit"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) })
|
||||
assert.equal(result.mode, "conflict")
|
||||
assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`))
|
||||
// Original rolling branch tip unchanged
|
||||
const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH])
|
||||
assert.equal(branchShaAfter, branchShaBefore)
|
||||
// No merge in progress
|
||||
let mergeHead = true
|
||||
try {
|
||||
gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"])
|
||||
} catch {
|
||||
mergeHead = false
|
||||
}
|
||||
assert.equal(mergeHead, false)
|
||||
void baseSha
|
||||
}
|
||||
|
||||
// 1c — identity-less / non-conflict merge failure → throws (does not fake conflict)
|
||||
{
|
||||
const dir = mktemp("docs-sync-merge-noid-")
|
||||
initRepoWithIdentity(dir)
|
||||
fs.writeFileSync(path.join(dir, "a.txt"), "base\n")
|
||||
gitIn(dir, ["add", "a.txt"])
|
||||
gitIn(dir, ["commit", "-m", "base"])
|
||||
gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH])
|
||||
fs.writeFileSync(path.join(dir, "b.txt"), "branch\n")
|
||||
gitIn(dir, ["add", "b.txt"])
|
||||
gitIn(dir, ["commit", "-m", "branch"])
|
||||
gitIn(dir, ["checkout", "main"])
|
||||
fs.writeFileSync(path.join(dir, "c.txt"), "main\n")
|
||||
gitIn(dir, ["add", "c.txt"])
|
||||
gitIn(dir, ["commit", "-m", "main"])
|
||||
gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"])
|
||||
gitIn(dir, ["checkout", DEFAULT_BRANCH])
|
||||
|
||||
// Strip identity so merge cannot create a commit
|
||||
gitIn(dir, ["config", "--unset", "user.name"])
|
||||
gitIn(dir, ["config", "--unset", "user.email"])
|
||||
|
||||
const env = {
|
||||
GIT_CONFIG_GLOBAL: "/dev/null",
|
||||
GIT_CONFIG_SYSTEM: "/dev/null",
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
}
|
||||
const git = (args) =>
|
||||
execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], {
|
||||
cwd: dir,
|
||||
env: { ...process.env, ...env },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
encoding: "utf8",
|
||||
}).toString().trim()
|
||||
|
||||
assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => {
|
||||
// Must throw the original merge error, not a merge --abort failure
|
||||
const msg = String(err?.stderr ?? err?.message ?? err)
|
||||
assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers to run edit.mjs / triage.mjs as child processes
|
||||
// ---------------------------------------------------------------------------
|
||||
function setupEditCwd(worthy, triage) {
|
||||
const cwd = mktemp("docs-sync-edit-")
|
||||
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2))
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2))
|
||||
return cwd
|
||||
}
|
||||
|
||||
function setupTriageCwd(digest) {
|
||||
const cwd = mktemp("docs-sync-triage-")
|
||||
fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true })
|
||||
fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2))
|
||||
return cwd
|
||||
}
|
||||
|
||||
function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) {
|
||||
const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
PATH: pathEnv,
|
||||
DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0",
|
||||
},
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
})
|
||||
return {
|
||||
status: result.status,
|
||||
stdout: String(result.stdout ?? ""),
|
||||
stderr: String(result.stderr ?? ""),
|
||||
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
error: result.error,
|
||||
}
|
||||
}
|
||||
|
||||
function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) {
|
||||
return {
|
||||
repo,
|
||||
number: n,
|
||||
title: `feat: sample ${n}`,
|
||||
url: `https://github.com/${repo}/pull/${n}`,
|
||||
author: "dev",
|
||||
merged_at: merged_at ?? "2026-07-20T12:00:00.000Z",
|
||||
labels: [],
|
||||
body: "body",
|
||||
files: [],
|
||||
files_total: 1,
|
||||
patch_excerpt: "",
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr)
|
||||
// ---------------------------------------------------------------------------
|
||||
function case2_defectB() {
|
||||
console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)")
|
||||
|
||||
const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: ["overview"],
|
||||
priority: "high",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const stderrText = "event stream disconnected DIAG-CASE2"
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText })
|
||||
|
||||
const started = Date.now()
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
// Enough budget for 3 attempts × tiny timeout
|
||||
EDIT_BUDGET_MINUTES: "5",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "1",
|
||||
},
|
||||
})
|
||||
const elapsed = Date.now() - started
|
||||
|
||||
assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`)
|
||||
// Backoff collapsed — 3 attempts without 60s+300s waits
|
||||
assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`)
|
||||
|
||||
assert.match(result.output, /stderr tail:/)
|
||||
assert.match(result.output, /DIAG-CASE2|event stream disconnected/)
|
||||
assert.match(result.output, /attempt 1/)
|
||||
assert.match(result.output, /attempt 2/)
|
||||
// 3 attempts
|
||||
assert.match(result.output, /attempt 3|failed after up to 3 attempts/)
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
|
||||
assert.equal(summary.length, 5)
|
||||
for (const e of summary) {
|
||||
assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`)
|
||||
}
|
||||
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 5)
|
||||
for (const u of uncovered) {
|
||||
assert.ok(u.reason, "uncovered reason present")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3 — watermark invariant
|
||||
// ---------------------------------------------------------------------------
|
||||
function case3_watermark() {
|
||||
console.log("case 3: watermark invariant")
|
||||
|
||||
const now = "2026-07-27T12:00:00.000Z"
|
||||
const nowMs = Date.parse(now)
|
||||
|
||||
const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" })
|
||||
const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" })
|
||||
const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" })
|
||||
const digest = [prA, prB, prC]
|
||||
|
||||
// all covered → processed-through === now
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" },
|
||||
{ pr: 11, url: prB.url, action: "skipped", reason: "already documented" },
|
||||
]
|
||||
const triage = [
|
||||
{ pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" },
|
||||
{ pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 0)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
assert.equal(through, now)
|
||||
}
|
||||
|
||||
// one uncovered → merged_at − 1 ms, strictly < now
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "updated x", reason: "" },
|
||||
{ pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage: [] })
|
||||
assert.equal(uncovered.length, 1)
|
||||
assert.equal(uncovered[0].url, prB.url)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString()
|
||||
assert.equal(through, expected)
|
||||
assert.ok(Date.parse(through) < nowMs)
|
||||
}
|
||||
|
||||
// several uncovered → earliest merge time wins
|
||||
{
|
||||
const worthy = [prA, prB, prC]
|
||||
const summary = [
|
||||
{ pr: 10, url: prA.url, action: "pending", reason: "fail" },
|
||||
{ pr: 12, url: prC.url, action: "pending", reason: "fail" },
|
||||
]
|
||||
// prB missing from summary entirely
|
||||
const uncovered = computeUncovered({ worthy, summary, triage: [] })
|
||||
assert.ok(uncovered.length >= 2)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
// earliest among A, B, C that are uncovered — A is earliest
|
||||
const times = uncovered
|
||||
.map((u) => digest.find((d) => d.url === u.url)?.merged_at)
|
||||
.filter(Boolean)
|
||||
.map((t) => Date.parse(t))
|
||||
const earliest = Math.min(...times)
|
||||
assert.equal(through, new Date(earliest - 1).toISOString())
|
||||
}
|
||||
|
||||
// summary missing/truncated while worthy non-empty → every worthy URL held back
|
||||
{
|
||||
const worthy = [prA, prB]
|
||||
const uncovered = computeUncovered({ worthy, summary: [], triage: [] })
|
||||
assert.equal(uncovered.length, 2)
|
||||
const through = computeProcessedThrough({ uncovered, digest, now })
|
||||
assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString())
|
||||
}
|
||||
|
||||
// noDiffReport three arms
|
||||
{
|
||||
const uncovered = [{ url: prA.url, reason: "edit batch failed" }]
|
||||
const arm1 = noDiffReport({ uncovered, sinceOverride: true })
|
||||
assert.ok(arm1.summary.includes(prA.url))
|
||||
assert.ok(arm1.warning, "override + uncovered → warning present")
|
||||
|
||||
const arm2 = noDiffReport({ uncovered: [], sinceOverride: true })
|
||||
assert.equal(arm2.warning, null, "override + empty uncovered → warning absent")
|
||||
|
||||
const arm3 = noDiffReport({ uncovered, sinceOverride: false })
|
||||
assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent")
|
||||
}
|
||||
|
||||
// triage pending:true backfill rows land in uncovered (consumption)
|
||||
{
|
||||
const triage = [
|
||||
{
|
||||
pr: 99,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/99",
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason: "not classified by triage",
|
||||
},
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
assert.equal(uncovered.length, 1)
|
||||
assert.equal(uncovered[0].url, triage[0].url)
|
||||
const through = computeProcessedThrough({
|
||||
uncovered,
|
||||
digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }],
|
||||
now,
|
||||
})
|
||||
assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString())
|
||||
}
|
||||
|
||||
// fallback field (post-plan repair)
|
||||
{
|
||||
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }]
|
||||
const fallback = "2026-07-17T00:00:00.000Z"
|
||||
// unresolved merged_at + parseable fallback → hold at fallback, warn
|
||||
const prevWarn = console.warn
|
||||
const warnings = []
|
||||
console.warn = (...a) => warnings.push(a.join(" "))
|
||||
try {
|
||||
const through = computeProcessedThrough({ uncovered, digest: [], now, fallback })
|
||||
assert.equal(through, new Date(fallback).toISOString())
|
||||
assert.ok(Date.parse(through) < nowMs)
|
||||
assert.ok(warnings.some((w) => w.includes("::warning::")))
|
||||
} finally {
|
||||
console.warn = prevWarn
|
||||
}
|
||||
|
||||
// unresolved + unparseable/missing fallback → throws
|
||||
assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i)
|
||||
assert.throws(
|
||||
() => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }),
|
||||
/fallback|SINCE|refusing/i,
|
||||
)
|
||||
|
||||
// resolved merged_at ignores fallback
|
||||
const throughResolved = computeProcessedThrough({
|
||||
uncovered: [{ url: prA.url, reason: "x" }],
|
||||
digest: [prA],
|
||||
now,
|
||||
fallback: "2020-01-01T00:00:00.000Z",
|
||||
})
|
||||
assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString())
|
||||
|
||||
// empty uncovered ignores fallback
|
||||
const throughEmpty = computeProcessedThrough({
|
||||
uncovered: [],
|
||||
digest: [],
|
||||
now,
|
||||
fallback: "2020-01-01T00:00:00.000Z",
|
||||
})
|
||||
assert.equal(throughEmpty, now)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 4 — routing and round trip
|
||||
// ---------------------------------------------------------------------------
|
||||
function case4_routing() {
|
||||
console.log("case 4: routing and round trip")
|
||||
|
||||
const summary = [
|
||||
{ pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" },
|
||||
{ pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" },
|
||||
{ pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" },
|
||||
]
|
||||
const triage = [
|
||||
{
|
||||
pr: 4,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/4",
|
||||
docs_worthy: false,
|
||||
pending: false,
|
||||
reason: "chore only",
|
||||
},
|
||||
{
|
||||
pr: 5,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/5",
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason: "triage failed to classify this PR",
|
||||
},
|
||||
]
|
||||
const worthy = [
|
||||
{ number: 1, url: summary[0].url },
|
||||
{ number: 2, url: summary[1].url },
|
||||
{ number: 3, url: summary[2].url },
|
||||
]
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered })
|
||||
|
||||
// pending appears in neither Changes nor Considered
|
||||
const changesText = changesRows.join("\n")
|
||||
const skippedText = skippedRows.join("\n")
|
||||
assert.ok(changesText.includes("pull/1"), "success in Changes")
|
||||
assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes")
|
||||
assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes")
|
||||
assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered")
|
||||
assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered")
|
||||
assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered")
|
||||
assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered")
|
||||
assert.ok(pendingRows.some((r) => r.includes("pull/3")))
|
||||
assert.ok(pendingRows.some((r) => r.includes("pull/5")))
|
||||
|
||||
// round-trip renderBody → extractSectionRows
|
||||
const through = "2026-07-20T09:59:59.999Z"
|
||||
const body = renderBody({
|
||||
date: "2026-07-27",
|
||||
since: "2026-07-17T00:00:00.000Z",
|
||||
through,
|
||||
changesRows,
|
||||
pendingRows,
|
||||
skippedRows,
|
||||
verified: true,
|
||||
draftReasons: [],
|
||||
note: "",
|
||||
})
|
||||
assert.ok(body.includes(`<!-- docs-sync: processed-through ${through} -->`))
|
||||
const extChanges = extractSectionRows(body, "changes")
|
||||
const extPending = extractSectionRows(body, "pending")
|
||||
const extSkipped = extractSectionRows(body, "skipped")
|
||||
assert.deepEqual(extChanges, changesRows)
|
||||
assert.deepEqual(extPending, pendingRows)
|
||||
assert.deepEqual(extSkipped, skippedRows)
|
||||
|
||||
// clean() prevents marker forgery in agent-generated row strings
|
||||
{
|
||||
const forgedRows = routeRows({
|
||||
summary: [
|
||||
{
|
||||
pr: 9,
|
||||
url: "https://github.com/Kilo-Org/cloud/pull/9",
|
||||
action: "skipped",
|
||||
reason: "x <!-- docs-sync:skipped:end --> injection",
|
||||
},
|
||||
],
|
||||
triage: [],
|
||||
uncovered: [],
|
||||
})
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("<!--"),
|
||||
"clean() must strip <!-- from reasons",
|
||||
)
|
||||
assert.ok(
|
||||
!forgedRows.skippedRows[0].includes("-->"),
|
||||
"clean() must strip --> from reasons",
|
||||
)
|
||||
const forgedBody = renderBody({
|
||||
date: "2026-07-27",
|
||||
since: "s",
|
||||
through: "t",
|
||||
changesRows: [],
|
||||
pendingRows: [],
|
||||
skippedRows: forgedRows.skippedRows,
|
||||
verified: true,
|
||||
draftReasons: [],
|
||||
note: "",
|
||||
})
|
||||
// Exactly one real section end marker — the forged sequences were stripped
|
||||
assert.equal((forgedBody.match(/<!--\s*docs-sync:skipped:end\s*-->/g) || []).length, 1)
|
||||
const extracted = extractSectionRows(forgedBody, "skipped")
|
||||
assert.equal(extracted.length, 1)
|
||||
assert.ok(extracted[0].includes("injection"))
|
||||
}
|
||||
|
||||
const legacyRows = [
|
||||
"| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |",
|
||||
"| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |",
|
||||
"| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |",
|
||||
"| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |",
|
||||
]
|
||||
const kept = dropLegacySkipped(legacyRows)
|
||||
assert.equal(kept.length, 1)
|
||||
assert.ok(kept[0].includes("pull/4"))
|
||||
assert.ok(!kept.some((r) => r.includes("edit pass failed")))
|
||||
assert.ok(!kept.some((r) => r.includes("triage failed to classify")))
|
||||
assert.ok(!kept.some((r) => r.includes("not classified by triage")))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 5 — re-collection window
|
||||
// ---------------------------------------------------------------------------
|
||||
function case5_recollection() {
|
||||
console.log("case 5: re-collection closes the loop")
|
||||
|
||||
const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8")
|
||||
// Query template must use merged:>=
|
||||
assert.ok(
|
||||
/merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc),
|
||||
"collect.mjs must search merged:>=since",
|
||||
)
|
||||
assert.match(collectSrc, /merged:>=/)
|
||||
|
||||
const mergedAt = "2026-07-22T15:30:00.000Z"
|
||||
const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }]
|
||||
const digest = [{ url: uncovered[0].url, merged_at: mergedAt }]
|
||||
const now = "2026-07-27T12:00:00.000Z"
|
||||
const since = computeProcessedThrough({ uncovered, digest, now })
|
||||
// held-back since is strictly before the uncovered PR's merged_at
|
||||
assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`)
|
||||
// And the query window merged:>=since therefore includes that PR
|
||||
assert.ok(Date.parse(mergedAt) >= Date.parse(since))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 6 — budgets
|
||||
// ---------------------------------------------------------------------------
|
||||
function case6_budgets() {
|
||||
console.log("case 6: budgets")
|
||||
|
||||
// --- edit budget ---
|
||||
{
|
||||
// 12 PRs = 3 batches of 5; budget too small for even one batch unit
|
||||
const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i))
|
||||
const worthy = prs
|
||||
const triage = prs.map((p) => ({
|
||||
pr: p.number,
|
||||
url: p.url,
|
||||
docs_worthy: true,
|
||||
reason: "needs docs",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}))
|
||||
const cwd = setupEditCwd(worthy, triage)
|
||||
const callLog = path.join(cwd, "kilo-calls.log")
|
||||
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
|
||||
|
||||
// EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50).
|
||||
// BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately.
|
||||
const result = runNodeScript(EDIT_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
EDIT_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
EDIT_BUDGET_MINUTES: "0.0001",
|
||||
EDIT_BATCH_TIMEOUT_MINUTES: "15",
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.match(result.output, /deferring \d+ PRs/)
|
||||
assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/)
|
||||
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
|
||||
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
|
||||
assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`)
|
||||
|
||||
const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8"))
|
||||
assert.ok(summary.every((e) => e.action === "pending"))
|
||||
const uncovered = computeUncovered({ worthy, summary, triage })
|
||||
assert.equal(uncovered.length, 12)
|
||||
assert.ok(summary.every((e) => e.action !== "skipped"))
|
||||
}
|
||||
|
||||
// --- triage budget ---
|
||||
{
|
||||
// CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk
|
||||
const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i))
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const callLog = path.join(cwd, "kilo-calls.log")
|
||||
const kiloDir = makeStubKiloDir({ mode: "record", callLog })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "0.0001",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
assert.match(result.output, /deferring \d+ PRs/)
|
||||
|
||||
const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : ""
|
||||
const callCount = calls ? calls.split("\n").filter(Boolean).length : 0
|
||||
assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`)
|
||||
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 30)
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
assert.ok(triage.every((e) => e.docs_worthy === false))
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
assert.equal(uncovered.length, 30)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 7 — applyCap both arms
|
||||
// ---------------------------------------------------------------------------
|
||||
function case7_cap() {
|
||||
console.log("case 7: applyCap")
|
||||
|
||||
const now = new Date("2026-07-27T12:00:00.000Z")
|
||||
const old = new Date("2026-06-01T00:00:00.000Z")
|
||||
|
||||
const prevLog = console.log
|
||||
const prevWarn = console.warn
|
||||
const logs = []
|
||||
const warnings = []
|
||||
console.log = (...a) => logs.push(a.join(" "))
|
||||
console.warn = (...a) => warnings.push(a.join(" "))
|
||||
try {
|
||||
// explicit:false + older than 14 days → clamped AND reported
|
||||
const a = applyCap(old, now, { explicit: false })
|
||||
assert.equal(a.clamped, true)
|
||||
assert.ok(a.since.getTime() > old.getTime())
|
||||
const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000)
|
||||
assert.equal(a.since.toISOString(), cap.toISOString())
|
||||
assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped")))
|
||||
|
||||
// explicit:true + older than 14 days → unchanged, skip reported
|
||||
logs.length = 0
|
||||
warnings.length = 0
|
||||
const b = applyCap(old, now, { explicit: true })
|
||||
assert.equal(b.clamped, false)
|
||||
assert.equal(b.since.toISOString(), old.toISOString())
|
||||
assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l)))
|
||||
} finally {
|
||||
console.log = prevLog
|
||||
console.warn = prevWarn
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 8 — triage.mjs outputs
|
||||
// ---------------------------------------------------------------------------
|
||||
function case8_triage() {
|
||||
console.log("case 8: triage pass outputs")
|
||||
|
||||
// 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present
|
||||
{
|
||||
const digest = [samplePr(301), samplePr(302), samplePr(303)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
SINCE_OVERRIDE: "true",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 3)
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
const summary = fs.readFileSync(summaryFile, "utf8")
|
||||
assert.match(summary, /triage pending/)
|
||||
for (const d of digest) {
|
||||
assert.ok(summary.includes(d.url), `summary lists ${d.url}`)
|
||||
}
|
||||
assert.match(result.output, /::warning::.*since-override/)
|
||||
}
|
||||
|
||||
// 8a Run B: SINCE_OVERRIDE unset → warning absent
|
||||
{
|
||||
const digest = [samplePr(311), samplePr(312)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.ok(triage.every((e) => e.pending === true))
|
||||
assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending"))
|
||||
assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset")
|
||||
}
|
||||
|
||||
// 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT
|
||||
{
|
||||
const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "mixed-triage" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
SINCE_OVERRIDE: "true",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
const worthy = triage.filter((e) => e.docs_worthy === true).length
|
||||
const pending = triage.filter((e) => e.pending === true).length
|
||||
assert.ok(worthy > 0, "mixed stub must produce worthy > 0")
|
||||
assert.ok(pending > 0, "mixed stub must leave some pending")
|
||||
assert.ok(
|
||||
!/::warning::.*since-override/.test(result.output),
|
||||
"override warning must be ABSENT when worthy > 0 (Upsert will run)",
|
||||
)
|
||||
}
|
||||
|
||||
// 8b — partial classification → missing URLs pending:true + computeUncovered
|
||||
{
|
||||
// One chunk of 4 PRs; stub classifies first 3 only
|
||||
const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)]
|
||||
const cwd = setupTriageCwd(digest)
|
||||
const kiloDir = makeStubKiloDir({ mode: "partial-triage" })
|
||||
const summaryFile = path.join(cwd, "step-summary.md")
|
||||
fs.writeFileSync(summaryFile, "")
|
||||
|
||||
const result = runNodeScript(TRIAGE_SCRIPT, {
|
||||
cwd,
|
||||
kiloDir,
|
||||
env: {
|
||||
TRIAGE_MODEL: "test/model",
|
||||
DOCS_SYNC_BACKOFF_MS: "0",
|
||||
TRIAGE_BUDGET_MINUTES: "30",
|
||||
GITHUB_STEP_SUMMARY: summaryFile,
|
||||
},
|
||||
})
|
||||
assert.equal(result.status, 0, result.output)
|
||||
const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8"))
|
||||
assert.equal(triage.length, 4)
|
||||
const missing = triage.filter((e) => e.reason === "not classified by triage")
|
||||
assert.ok(missing.length >= 1, "backfill must mark unclassified URLs")
|
||||
assert.ok(missing.every((e) => e.pending === true))
|
||||
const uncovered = computeUncovered({ worthy: [], summary: [], triage })
|
||||
for (const m of missing) {
|
||||
assert.ok(
|
||||
uncovered.some((u) => u.url === m.url),
|
||||
`${m.url} must appear in computeUncovered`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const cases = [
|
||||
case1_mergeOrFallback,
|
||||
case2_defectB,
|
||||
case3_watermark,
|
||||
case4_routing,
|
||||
case5_recollection,
|
||||
case6_budgets,
|
||||
case7_cap,
|
||||
case8_triage,
|
||||
]
|
||||
let failed = 0
|
||||
for (const fn of cases) {
|
||||
try {
|
||||
fn()
|
||||
console.log(` ok: ${fn.name}`)
|
||||
} catch (err) {
|
||||
failed++
|
||||
console.error(` FAIL: ${fn.name}`)
|
||||
console.error(err)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
console.error(`\nselftest: ${failed} case(s) failed`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("\nselftest: all cases passed")
|
||||
}
|
||||
|
||||
main()
|
||||
+120
-42
@@ -6,52 +6,82 @@
|
||||
* A daily window holds ~30-50 PRs; a replay can hold several hundred. A
|
||||
* single triage call over that volume truncates its JSON output, so the
|
||||
* digest is split into chunks of CHUNK_SIZE and each chunk is triaged with
|
||||
* its own `kilo run` call. A chunk that fails twice is degraded to
|
||||
* "unclassified" entries (docs_worthy=false) instead of failing the run —
|
||||
* the PR body then shows those PRs as skipped, visible to reviewers.
|
||||
* its own `kilo run` call. A chunk that fails, is only partially classified,
|
||||
* or is deferred by the wall-clock budget is marked pending:true (still
|
||||
* docs_worthy:false so filter-worthy excludes it) so the watermark holds
|
||||
* back and the next run re-collects those PRs.
|
||||
*
|
||||
* Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by
|
||||
* the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script.
|
||||
* Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { parseTriageEntries } from "./extract-json.mjs"
|
||||
import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs"
|
||||
|
||||
const CHUNK_SIZE = 25
|
||||
const ATTEMPTS = 2
|
||||
const ATTEMPTS = 3
|
||||
const OUT_DIR = "docs-sync-out"
|
||||
const CHUNK_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
||||
const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8")
|
||||
const model = process.env.TRIAGE_MODEL
|
||||
if (!model) throw new Error("TRIAGE_MODEL is required")
|
||||
|
||||
const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35
|
||||
|
||||
const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8"))
|
||||
|
||||
function triageChunk(chunk, index) {
|
||||
function formatCause(result) {
|
||||
const bits = []
|
||||
if (result.timedOut) bits.push("timed out")
|
||||
if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`)
|
||||
if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200))
|
||||
return bits.join("; ") || "no diagnostic"
|
||||
}
|
||||
|
||||
function pendingEntry(d, reason) {
|
||||
return {
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
pending: true,
|
||||
reason,
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}
|
||||
}
|
||||
|
||||
function triageChunk(chunk, index, budgetDeadline) {
|
||||
const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json`
|
||||
fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2))
|
||||
|
||||
let lastCause = "triage failed to classify this PR"
|
||||
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
|
||||
let raw
|
||||
try {
|
||||
// Message positional first: --file is multi-value and would otherwise
|
||||
// consume a trailing message as a file path ("File not found").
|
||||
raw = execFileSync(
|
||||
"kilo",
|
||||
["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
{ encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] },
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}`
|
||||
console.warn(
|
||||
`chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`,
|
||||
)
|
||||
} catch (err) {
|
||||
const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n")
|
||||
console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`)
|
||||
continue
|
||||
break
|
||||
}
|
||||
fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
const entries = parseTriageEntries(raw)
|
||||
|
||||
const result = runKilo({
|
||||
args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile],
|
||||
timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left),
|
||||
streamStdout: false,
|
||||
label: `triage chunk ${index} attempt ${attempt}`,
|
||||
})
|
||||
|
||||
const raw = result.stdout
|
||||
if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw)
|
||||
|
||||
const entries = raw ? parseTriageEntries(raw) : null
|
||||
if (entries) {
|
||||
// An entry for a PR outside this chunk must not win the shared dedupe
|
||||
// against the chunk that actually owns it — drop foreign entries.
|
||||
@@ -62,18 +92,35 @@ function triageChunk(chunk, index) {
|
||||
}
|
||||
if (owned.length > 0) return owned
|
||||
}
|
||||
console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`)
|
||||
|
||||
// Exit 0 is not success: unparseable output is a failure logged WITH
|
||||
// the captured stderrTail and exit code on every attempt.
|
||||
const cause = formatCause(result)
|
||||
lastCause = `triage chunk ${index}: ${cause}`
|
||||
console.warn(
|
||||
`chunk ${index} attempt ${attempt}: no valid JSON in output` +
|
||||
` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` +
|
||||
(result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"),
|
||||
)
|
||||
|
||||
if (attempt < ATTEMPTS) {
|
||||
const wait = backoffMsForAttempt(attempt)
|
||||
const afterWait = remainingMs(budgetDeadline) - wait
|
||||
if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) {
|
||||
console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`)
|
||||
sleepSync(wait)
|
||||
} else if (wait > 0) {
|
||||
console.warn(
|
||||
`chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`)
|
||||
return chunk.map((d) => ({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "triage failed to classify this PR",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
}))
|
||||
console.warn(
|
||||
`::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`,
|
||||
)
|
||||
return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`))
|
||||
}
|
||||
|
||||
const chunks = []
|
||||
@@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) {
|
||||
}
|
||||
console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`)
|
||||
|
||||
const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES)
|
||||
const merged = []
|
||||
const seen = new Set()
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (const e of triageChunk(chunks[i], i)) {
|
||||
const left = remainingMs(budgetDeadline)
|
||||
if (left < CHUNK_TIMEOUT_MS) {
|
||||
const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0)
|
||||
console.warn(
|
||||
`stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`,
|
||||
)
|
||||
const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)`
|
||||
for (let j = i; j < chunks.length; j++) {
|
||||
for (const d of chunks[j]) {
|
||||
if (seen.has(d.url)) continue
|
||||
seen.add(d.url)
|
||||
merged.push(pendingEntry(d, cause))
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for (const e of triageChunk(chunks[i], i, budgetDeadline)) {
|
||||
if (seen.has(e.url)) continue
|
||||
seen.add(e.url)
|
||||
merged.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage: every digest PR gets a triage entry so the PR body's skipped
|
||||
// table is complete. Unclassified defaults to not-docs-worthy (conservative).
|
||||
// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and
|
||||
// any other missing URL are pending:true — not a genuine "not worthy" verdict.
|
||||
for (const d of digest) {
|
||||
if (seen.has(d.url)) continue
|
||||
merged.push({
|
||||
pr: d.number,
|
||||
url: d.url,
|
||||
docs_worthy: false,
|
||||
reason: "not classified by triage",
|
||||
target_sections: [],
|
||||
priority: "medium",
|
||||
})
|
||||
merged.push(pendingEntry(d, "not classified by triage"))
|
||||
}
|
||||
|
||||
fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2))
|
||||
const worthy = merged.filter((e) => e.docs_worthy).length
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`)
|
||||
const pending = merged.filter((e) => e.pending === true)
|
||||
console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`)
|
||||
|
||||
// Upsert is gated off when worthy == 0, so triage emits its own Step Summary
|
||||
// listing every PR it marked pending:true and why.
|
||||
if (pending.length > 0) {
|
||||
const lines = pending.map((e) => `- [${e.url}] ${e.reason}`)
|
||||
appendSummary(
|
||||
`### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Replay warning (S2j): warn IFF since-override AND something pending AND
|
||||
// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs).
|
||||
const sinceOverride = process.env.SINCE_OVERRIDE === "true"
|
||||
if (sinceOverride && pending.length > 0 && worthy === 0) {
|
||||
console.warn(
|
||||
"::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body",
|
||||
)
|
||||
}
|
||||
|
||||
+245
-15
@@ -8,6 +8,14 @@
|
||||
* diff exceeds the file cap or verification failed. The PR body carries
|
||||
* marker-delimited sections so later runs can append rows, plus a
|
||||
* machine-readable processed-through watermark.
|
||||
*
|
||||
* Watermark invariant: processed-through never moves past a PR that has no
|
||||
* terminal outcome. Terminal := action !== "pending" (a deliberate agent
|
||||
* "skipped" IS terminal). Uncovered PRs hold the marker at earliest
|
||||
* merged_at − 1 ms so collect's merged:>=since re-collects them next run.
|
||||
* Three review rounds found four independent defects in a queue-based
|
||||
* alternative (unreachable gate, empty-PR creation, cap-overflow loss,
|
||||
* draft-state corruption); a held-back watermark has none of those modes.
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process"
|
||||
@@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url"
|
||||
const BRANCH = process.env.BRANCH || "docs/auto-sync"
|
||||
const FILE_CAP = 15
|
||||
const ROW_CAP = 150
|
||||
const PENDING_DISPLAY_CAP = 60
|
||||
const SUMMARY_FILE = ".docs-sync-summary.json"
|
||||
const DOCS_PATH = "packages/kilo-docs"
|
||||
|
||||
@@ -42,6 +51,11 @@ function skippedRow(e) {
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
function pendingRow(e) {
|
||||
const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ")
|
||||
return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |`
|
||||
}
|
||||
|
||||
export function extractSectionRows(body, name) {
|
||||
const m = String(body ?? "").match(
|
||||
new RegExp(`<!--\\s*docs-sync:${name}:start\\s*-->([\\s\\S]*?)<!--\\s*docs-sync:${name}:end\\s*-->`),
|
||||
@@ -50,7 +64,14 @@ export function extractSectionRows(body, name) {
|
||||
return m[1]
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l))
|
||||
.filter(
|
||||
(l) =>
|
||||
l.startsWith("|") &&
|
||||
!l.startsWith("| ---") &&
|
||||
!/^\|\s*Docs change/.test(l) &&
|
||||
!/^\|\s*PR\s*\|/.test(l) &&
|
||||
!/^\|\s*Why\s*\|/.test(l),
|
||||
)
|
||||
}
|
||||
|
||||
function section(name, header, rows) {
|
||||
@@ -58,7 +79,12 @@ function section(name, header, rows) {
|
||||
return `<!-- docs-sync:${name}:start -->\n${body}\n<!-- docs-sync:${name}:end -->`
|
||||
}
|
||||
|
||||
export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) {
|
||||
export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) {
|
||||
const pendingDisplay =
|
||||
pendingRows.length > PENDING_DISPLAY_CAP
|
||||
? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`]
|
||||
: pendingRows
|
||||
|
||||
return `## Automated docs sync — ${date}
|
||||
|
||||
This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents.
|
||||
@@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf
|
||||
|
||||
${section("changes", "| Docs change | Source |", changesRows)}
|
||||
|
||||
### Pending — will retry
|
||||
|
||||
${section("pending", "| PR | Why |", pendingDisplay)}
|
||||
|
||||
### Considered, no docs change needed
|
||||
|
||||
${section("skipped", "| PR | Reason |", skippedRows)}
|
||||
@@ -100,32 +130,222 @@ function readJson(path, fallback) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending")
|
||||
* ∪ (triage entries with pending: true). A worthy PR is covered iff it has a
|
||||
* summary row whose action !== "pending" and carries no triage pending flag.
|
||||
*/
|
||||
export function computeUncovered({ worthy, summary, triage }) {
|
||||
const worthyList = Array.isArray(worthy) ? worthy : []
|
||||
const summaryList = Array.isArray(summary) ? summary : []
|
||||
const triageList = Array.isArray(triage) ? triage : []
|
||||
|
||||
const summaryByUrl = new Map()
|
||||
for (const e of summaryList) {
|
||||
if (e?.url) summaryByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
const triagePendingByUrl = new Map()
|
||||
for (const e of triageList) {
|
||||
if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e)
|
||||
}
|
||||
|
||||
/** @type {Map<string, { url: string, pr?: number, reason: string }>} */
|
||||
const out = new Map()
|
||||
|
||||
for (const w of worthyList) {
|
||||
const url = w?.url
|
||||
if (!url) continue
|
||||
const row = summaryByUrl.get(url)
|
||||
if (!row) {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: w.number ?? w.pr,
|
||||
reason: "no edit summary row (edit pass did not cover this PR)",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (row.action === "pending") {
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: row.pr ?? w.number ?? w.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Summary pending rows for URLs not in worthy (defensive).
|
||||
for (const row of summaryList) {
|
||||
if (row?.action === "pending" && row.url && !out.has(row.url)) {
|
||||
out.set(row.url, {
|
||||
url: row.url,
|
||||
pr: row.pr,
|
||||
reason: row.reason || "edit pass pending",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const [url, e] of triagePendingByUrl) {
|
||||
if (out.has(url)) continue
|
||||
out.set(url, {
|
||||
url,
|
||||
pr: e.pr,
|
||||
reason: e.reason || "triage pending",
|
||||
})
|
||||
}
|
||||
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* processed-through = now when uncovered is empty; otherwise earliest
|
||||
* merged_at among uncovered PRs minus 1 ms (from digest-full.json).
|
||||
* When uncovered is non-empty but no merged_at resolves, hold at
|
||||
* `fallback` (the run's window start / SINCE): every uncovered PR was
|
||||
* collected via merged:>=since, so holding there re-collects all of them.
|
||||
* Never advance past unresolved uncovered PRs (Defect-B permanent-loss).
|
||||
*/
|
||||
export function computeProcessedThrough({ uncovered, digest, now, fallback }) {
|
||||
const nowIso = typeof now === "string" ? now : new Date(now).toISOString()
|
||||
if (!uncovered || uncovered.length === 0) return nowIso
|
||||
|
||||
const digestList = Array.isArray(digest) ? digest : []
|
||||
const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d]))
|
||||
|
||||
let earliest = null
|
||||
for (const u of uncovered) {
|
||||
const d = byUrl.get(u.url)
|
||||
const mergedAt = d?.merged_at
|
||||
if (!mergedAt) continue
|
||||
const t = Date.parse(mergedAt)
|
||||
if (!Number.isFinite(t)) continue
|
||||
if (earliest === null || t < earliest) earliest = t
|
||||
}
|
||||
|
||||
if (earliest === null) {
|
||||
// digest-full missing/corrupt while uncovered is non-empty: hold at
|
||||
// window start so collect's merged:>=since re-collects every PR.
|
||||
// Never use 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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -5,9 +5,13 @@ name: docs-sync
|
||||
# triages them for docs relevance, runs Kilo CLI headless to update
|
||||
# packages/kilo-docs, and maintains one rolling PR for human review.
|
||||
#
|
||||
# Security posture: scheduled/manual only, checks out main, never executes
|
||||
# code from PR branches. State is derived from the bot's own PRs (watermark
|
||||
# marker in the PR body), so missed or failed runs self-heal on the next run.
|
||||
# Security posture: scheduled/manual runs check out the dispatched ref and may
|
||||
# push/comment with write permissions and org secrets. PR runs (paths-limited to
|
||||
# this workflow and .github/docs-sync/**) execute branch code only in a
|
||||
# read-only, secretless `selftest` job that never pushes, comments, or calls an
|
||||
# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only.
|
||||
# State is derived from the bot's own PRs (watermark marker in the PR body), so
|
||||
# missed or failed runs self-heal on the next run.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -22,6 +26,10 @@ on:
|
||||
description: "Collect + triage only, no edits, no PR"
|
||||
type: boolean
|
||||
default: false
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/docs-sync/**"
|
||||
- ".github/workflows/docs-sync.yml"
|
||||
|
||||
permissions:
|
||||
contents: write # push the rolling branch, create the auto-docs label
|
||||
@@ -29,7 +37,7 @@ permissions:
|
||||
issues: write # comment on the rolling PR
|
||||
|
||||
concurrency:
|
||||
group: docs-sync
|
||||
group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
@@ -37,9 +45,28 @@ env:
|
||||
EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }}
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
selftest:
|
||||
if: github.repository == 'Kilo-Org/kilocode'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
sync:
|
||||
if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
# Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve.
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
# Both are required: without KILO_ORG_ID the gateway bills the key
|
||||
@@ -52,12 +79,20 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # prepare-branch merges main into the rolling branch
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Run docs-sync selftest
|
||||
run: node .github/docs-sync/selftest.mjs
|
||||
|
||||
- name: Install Kilo CLI
|
||||
run: |
|
||||
npm install -g @kilocode/cli
|
||||
@@ -79,6 +114,8 @@ jobs:
|
||||
- name: Triage merged PRs (LLM, chunked)
|
||||
id: triage
|
||||
if: steps.collect.outputs.count != '0'
|
||||
env:
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
run: node .github/docs-sync/triage.mjs
|
||||
|
||||
- name: Filter docs-worthy PRs
|
||||
@@ -104,8 +141,18 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: node .github/docs-sync/prepare-branch.mjs
|
||||
|
||||
# After prepare-branch checks out the rolling branch and merges main, the
|
||||
# worktree holds main's scripts. Restore the dispatched ref's copies so a
|
||||
# branch-dispatch AC9 run actually exercises the fixed code. git restore
|
||||
# (not checkout) leaves them unstaged so upsert-pr's bare commit won't
|
||||
# include them in the docs PR.
|
||||
- name: Restore docs-sync scripts from the dispatched ref
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
run: git restore --source=${{ github.sha }} -- .github/docs-sync
|
||||
|
||||
- name: Update docs (Kilo CLI, batched)
|
||||
if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true
|
||||
continue-on-error: true
|
||||
run: node .github/docs-sync/edit.mjs
|
||||
|
||||
- name: Verify docs build and tests
|
||||
@@ -122,6 +169,7 @@ jobs:
|
||||
id: fix
|
||||
if: steps.verify.outcome == 'failure'
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }}
|
||||
run: |
|
||||
@@ -150,6 +198,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PROCESSED_THROUGH: ${{ steps.wm.outputs.now }}
|
||||
SINCE: ${{ steps.wm.outputs.since }}
|
||||
SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }}
|
||||
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||
PREP_MODE: ${{ steps.prep.outputs.mode }}
|
||||
PR_NUMBER: ${{ steps.prep.outputs.pr_number }}
|
||||
|
||||
@@ -142,23 +142,43 @@ jobs:
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Write signing secrets to temp files
|
||||
run: |
|
||||
dir="$RUNNER_TEMP/jetbrains-signing"
|
||||
mkdir -m 700 -p "$dir"
|
||||
chain="$dir/certificate-chain.pem"
|
||||
key="$dir/private-key.pem"
|
||||
umask 077
|
||||
printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain"
|
||||
printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key"
|
||||
chmod 600 "$chain" "$key"
|
||||
echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV"
|
||||
echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV"
|
||||
env:
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
|
||||
- name: Build signed bundled plugin
|
||||
working-directory: packages/kilo-jetbrains
|
||||
run: |
|
||||
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \
|
||||
-Pproduction=true \
|
||||
-Pkilo.version="$VERSION" \
|
||||
-Pkilo.channel="$CHANNEL" \
|
||||
args=(
|
||||
-Pproduction=true
|
||||
-Pkilo.version="$VERSION"
|
||||
-Pkilo.channel="$CHANNEL"
|
||||
-Pkilo.cli.bundled=true
|
||||
)
|
||||
./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
VERSION: ${{ needs.validate.outputs.version }}
|
||||
CHANNEL: ${{ needs.validate.outputs.channel }}
|
||||
JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }}
|
||||
JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }}
|
||||
JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
- name: Remove signing secret temp files
|
||||
if: always()
|
||||
run: rm -rf "$RUNNER_TEMP/jetbrains-signing"
|
||||
|
||||
- name: Resolve bundled archive
|
||||
id: archive
|
||||
run: |
|
||||
|
||||
@@ -159,6 +159,10 @@ When a pull request or merge request is opened or updated:
|
||||
|
||||
Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer.
|
||||
|
||||
{% callout type="info" title="Bot-generated PRs are ignored by default" %}
|
||||
Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes.
|
||||
{% /callout %}
|
||||
|
||||
## Review Styles
|
||||
|
||||
### Strict
|
||||
@@ -236,3 +240,4 @@ The Review Agent is ideal for:
|
||||
- Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`.
|
||||
- The agent will only run on **selected repositories**.
|
||||
- During beta, review capacity may be throttled for extremely large PRs.
|
||||
- PRs/MRs opened by bots (e.g. Dependabot, Renovate) are ignored by default.
|
||||
|
||||
@@ -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
|
||||
|
||||
+27
-4
@@ -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")
|
||||
|
||||
+6
@@ -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)
|
||||
|
||||
+3
@@ -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)) }
|
||||
|
||||
|
||||
+43
@@ -90,6 +90,32 @@ class KiloBackendChatManagerTest {
|
||||
assertEquals("{}", mock.lastUnrevertBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete message sends queued message delete request`() = runBlocking {
|
||||
val port = mock.start()
|
||||
val chat = KiloBackendChatManager(scope, TestLog())
|
||||
chat.start(OkHttpClient(), port, MutableSharedFlow())
|
||||
|
||||
val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")
|
||||
|
||||
assertTrue(result)
|
||||
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
|
||||
assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory="))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete message returns false for queued drop miss`() = runBlocking {
|
||||
val port = mock.start()
|
||||
val chat = KiloBackendChatManager(scope, TestLog())
|
||||
chat.start(OkHttpClient(), port, MutableSharedFlow())
|
||||
mock.messageDeleteResponse = "false"
|
||||
|
||||
val result = chat.deleteMessage("ses_abc", "/test/project", "msg1")
|
||||
|
||||
assertEquals(false, result)
|
||||
assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `revert failure throws on non successful response`() = runBlocking {
|
||||
val port = mock.start()
|
||||
@@ -202,4 +228,21 @@ class KiloBackendChatManagerTest {
|
||||
assertEquals("ses_abc", event.sessionID)
|
||||
assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `global message event type is extracted from payload`() = runBlocking {
|
||||
val port = mock.start()
|
||||
val sse = MutableSharedFlow<SseEvent>(replay = 8)
|
||||
val chat = KiloBackendChatManager(scope, TestLog())
|
||||
chat.start(OkHttpClient(), port, sse)
|
||||
|
||||
val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } }
|
||||
withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } }
|
||||
sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}"""))
|
||||
|
||||
val event = received.await()
|
||||
assertTrue(event is ChatEventDto.SessionQueueChanged)
|
||||
assertEquals("ses_abc", event.sessionID)
|
||||
assertEquals(listOf("msg2"), event.queued)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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("""
|
||||
|
||||
+7
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType
|
||||
import org.jetbrains.intellij.platform.gradle.tasks.InstrumentCodeTask
|
||||
import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask
|
||||
import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.PluginInstallationTarget
|
||||
import java.io.File
|
||||
import java.time.LocalDate
|
||||
|
||||
group = "ai.kilocode.jetbrains"
|
||||
@@ -207,8 +208,12 @@ intellijPlatform {
|
||||
}
|
||||
|
||||
signing {
|
||||
// CI passes raw secret content so signing can run without writing secrets to disk.
|
||||
// Local release builds can still point these properties at pre-existing secret files.
|
||||
certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN")
|
||||
privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY")
|
||||
certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { File(it) })
|
||||
privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { File(it) })
|
||||
password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD")
|
||||
}
|
||||
|
||||
@@ -220,6 +225,10 @@ intellijPlatform {
|
||||
}
|
||||
|
||||
tasks {
|
||||
named("verifyPluginSignature") {
|
||||
dependsOn("signPlugin")
|
||||
}
|
||||
|
||||
withType<InstrumentCodeTask> {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
+3
@@ -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) }
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+27
-1
@@ -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
|
||||
|
||||
+15
@@ -74,6 +74,9 @@ class SessionModel {
|
||||
|
||||
private var revert: SessionRevertDto? = null
|
||||
|
||||
var queued: Set<String> = emptySet()
|
||||
private set
|
||||
|
||||
var header: SessionHeaderSnapshot = emptyHeader()
|
||||
private set
|
||||
|
||||
@@ -125,6 +128,9 @@ class SessionModel {
|
||||
return idx >= 0 && pos >= idx
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun isQueued(id: String): Boolean = id in queued
|
||||
|
||||
@RequiresEdt
|
||||
fun turn(id: String): Turn? = turnEntries[id]
|
||||
|
||||
@@ -295,6 +301,13 @@ class SessionModel {
|
||||
fire(SessionModelEvent.RevertChanged(revert))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setQueued(ids: Set<String>) {
|
||||
if (queued == ids) return
|
||||
queued = ids
|
||||
fire(SessionModelEvent.QueueChanged(ids))
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setDiff(diff: List<DiffFileDto>) {
|
||||
this.diff = diff
|
||||
@@ -329,6 +342,7 @@ class SessionModel {
|
||||
hiddenText.clear()
|
||||
session = null
|
||||
revert = null
|
||||
queued = emptySet()
|
||||
state = SessionState.Idle
|
||||
diff = emptyList()
|
||||
todos = emptyList()
|
||||
@@ -363,6 +377,7 @@ class SessionModel {
|
||||
hiddenText.clear()
|
||||
session = null
|
||||
revert = null
|
||||
queued = emptySet()
|
||||
state = SessionState.Idle
|
||||
diff = emptyList()
|
||||
todos = emptyList()
|
||||
|
||||
+3
@@ -60,6 +60,9 @@ sealed class SessionModelEvent {
|
||||
data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() {
|
||||
override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}"
|
||||
}
|
||||
data class QueueChanged(val queued: Set<String>) : SessionModelEvent() {
|
||||
override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]"
|
||||
}
|
||||
data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() {
|
||||
override fun toString() = "HeaderUpdated visible=${header.visible}"
|
||||
}
|
||||
|
||||
+23
-3
@@ -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.
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+16
-9
@@ -212,7 +212,7 @@ class PromptPanel(
|
||||
isFocusPainted = false
|
||||
addActionListener {
|
||||
syncTooltip()
|
||||
val id = if (busy) StopSessionAction.ID else SendPromptAction.ID
|
||||
val id = if (busy && !hasDraft()) StopSessionAction.ID else SendPromptAction.ID
|
||||
val action = ActionManager.getInstance().getAction(id)
|
||||
?: return@addActionListener
|
||||
val ctx = DataManager.getInstance().getDataContext(button)
|
||||
@@ -258,7 +258,7 @@ class PromptPanel(
|
||||
private var request = 0L
|
||||
|
||||
override val isSendEnabled: Boolean
|
||||
get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
|
||||
get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty())
|
||||
|
||||
override val isStopEnabled: Boolean
|
||||
get() = busy
|
||||
@@ -273,6 +273,7 @@ class PromptPanel(
|
||||
syncEditorHeight()
|
||||
triggerCompletion(e)
|
||||
syncHighlights()
|
||||
syncButton()
|
||||
onChange()
|
||||
}
|
||||
})
|
||||
@@ -418,7 +419,7 @@ class PromptPanel(
|
||||
fun setBusy(value: Boolean) {
|
||||
busy = value
|
||||
if (value) invalidateEnhancement() else syncEnhance()
|
||||
button.icon = if (value) STOP_ICON else SEND_ICON
|
||||
syncButton()
|
||||
syncTooltip()
|
||||
}
|
||||
|
||||
@@ -628,6 +629,11 @@ class PromptPanel(
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun syncButton() {
|
||||
button.icon = if (busy && !hasDraft()) STOP_ICON else SEND_ICON
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
private fun submit(src: String) {
|
||||
if (!isSendEnabled) return
|
||||
@@ -885,19 +891,20 @@ class PromptPanel(
|
||||
}
|
||||
|
||||
private fun tooltip(): String {
|
||||
val id = if (busy) StopSessionAction.ID else SendPromptAction.ID
|
||||
val text = if (busy) {
|
||||
val stop = busy && !hasDraft()
|
||||
val id = if (stop) StopSessionAction.ID else SendPromptAction.ID
|
||||
val text = if (stop) {
|
||||
KiloBundle.message("prompt.button.stop")
|
||||
} else {
|
||||
KiloBundle.message("prompt.button.send")
|
||||
}
|
||||
val tip = KeymapUtil.createTooltipText(text, id)
|
||||
if (busy) return tip
|
||||
val stop = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID)
|
||||
if (stop.isEmpty()) return tip
|
||||
if (stop) return tip
|
||||
val shortcut = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID)
|
||||
if (shortcut.isEmpty()) return tip
|
||||
return XmlStringUtil.wrapInHtml(
|
||||
XmlStringUtil.escapeString(tip) + "<br>" +
|
||||
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop))
|
||||
XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut))
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+61
-7
@@ -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()
|
||||
|
||||
+7
@@ -36,6 +36,7 @@ class TurnView(
|
||||
private val repo: String? = null,
|
||||
private val hover: ((PartView, Boolean) -> Unit)? = null,
|
||||
private val revert: ((String) -> Unit)? = null,
|
||||
private val deleteQueued: ((String) -> Unit)? = null,
|
||||
) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView {
|
||||
|
||||
private val messages = LinkedHashMap<String, MessageView>()
|
||||
@@ -71,6 +72,12 @@ class TurnView(
|
||||
return view
|
||||
}
|
||||
|
||||
@RequiresEdt
|
||||
fun setQueued(active: Boolean, onDelete: (String) -> Unit) {
|
||||
val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return
|
||||
anchor.setQueued(active) { onDelete(id) }
|
||||
}
|
||||
|
||||
/** Remove the [MessageView] for [msgId] if present. */
|
||||
fun removeMessage(msgId: String) {
|
||||
removeMessageChanged(msgId)
|
||||
|
||||
@@ -31,6 +31,8 @@ session.copy.hover=Copy
|
||||
session.copy.prompt=Copy prompt
|
||||
session.copy.response=Copy response
|
||||
session.copy.copied=Copied
|
||||
session.queued=Queued
|
||||
session.queued.remove=Remove queued message
|
||||
session.drop.files.title=Drop files here
|
||||
session.drop.files.subtitle=to add them to the prompt
|
||||
session.file.missing=Couldn''t find ''{0}'' in this repository.
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=نسخ
|
||||
session.copy.prompt=نسخ الموجه
|
||||
session.copy.response=نسخ الرد
|
||||
session.copy.copied=تم النسخ
|
||||
session.queued=في قائمة الانتظار
|
||||
session.queued.remove=إزالة الرسالة من قائمة الانتظار
|
||||
session.tab.new=جلسة جديدة
|
||||
session.tab.untitled=جلسة بدون عنوان
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopiraj
|
||||
session.copy.prompt=Kopiraj prompt
|
||||
session.copy.response=Kopiraj odgovor
|
||||
session.copy.copied=Kopirano
|
||||
session.queued=U redu čekanja
|
||||
session.queued.remove=Ukloni poruku iz reda čekanja
|
||||
session.tab.new=Nova sesija
|
||||
session.tab.untitled=Sesija bez naslova
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopiér
|
||||
session.copy.prompt=Kopiér prompt
|
||||
session.copy.response=Kopiér svar
|
||||
session.copy.copied=Kopieret
|
||||
session.queued=I kø
|
||||
session.queued.remove=Fjern besked fra køen
|
||||
session.tab.new=Ny session
|
||||
session.tab.untitled=Unavngivet session
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopieren
|
||||
session.copy.prompt=Prompt kopieren
|
||||
session.copy.response=Antwort kopieren
|
||||
session.copy.copied=Kopiert
|
||||
session.queued=In Warteschlange
|
||||
session.queued.remove=Nachricht aus Warteschlange entfernen
|
||||
session.tab.new=Neue Sitzung
|
||||
session.tab.untitled=Unbenannte Sitzung
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Copiar
|
||||
session.copy.prompt=Copiar prompt
|
||||
session.copy.response=Copiar respuesta
|
||||
session.copy.copied=Copiado
|
||||
session.queued=En cola
|
||||
session.queued.remove=Eliminar mensaje en cola
|
||||
session.tab.new=Nueva sesión
|
||||
session.tab.untitled=Sesión sin título
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Copier
|
||||
session.copy.prompt=Copier le prompt
|
||||
session.copy.response=Copier la réponse
|
||||
session.copy.copied=Copié
|
||||
session.queued=En attente
|
||||
session.queued.remove=Supprimer le message en attente
|
||||
session.tab.new=Nouvelle session
|
||||
session.tab.untitled=Session sans titre
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=コピー
|
||||
session.copy.prompt=プロンプトをコピー
|
||||
session.copy.response=応答をコピー
|
||||
session.copy.copied=コピーしました
|
||||
session.queued=キュー済み
|
||||
session.queued.remove=キュー済みメッセージを削除
|
||||
session.tab.new=新しいセッション
|
||||
session.tab.untitled=名前なしのセッション
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=복사
|
||||
session.copy.prompt=프롬프트 복사
|
||||
session.copy.response=응답 복사
|
||||
session.copy.copied=복사됨
|
||||
session.queued=대기 중
|
||||
session.queued.remove=대기 중인 메시지 제거
|
||||
session.tab.new=새 세션
|
||||
session.tab.untitled=제목 없는 세션
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopiëren
|
||||
session.copy.prompt=Prompt kopiëren
|
||||
session.copy.response=Antwoord kopiëren
|
||||
session.copy.copied=Gekopieerd
|
||||
session.queued=In wachtrij
|
||||
session.queued.remove=Bericht uit wachtrij verwijderen
|
||||
session.tab.new=Nieuwe sessie
|
||||
session.tab.untitled=Naamloze sessie
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopier
|
||||
session.copy.prompt=Kopier prompt
|
||||
session.copy.response=Kopier svar
|
||||
session.copy.copied=Kopiert
|
||||
session.queued=I kø
|
||||
session.queued.remove=Fjern melding fra køen
|
||||
session.tab.new=Ny økt
|
||||
session.tab.untitled=Uten tittel
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopiuj
|
||||
session.copy.prompt=Kopiuj prompt
|
||||
session.copy.response=Kopiuj odpowiedź
|
||||
session.copy.copied=Skopiowano
|
||||
session.queued=W kolejce
|
||||
session.queued.remove=Usuń wiadomość z kolejki
|
||||
session.tab.new=Nowa sesja
|
||||
session.tab.untitled=Sesja bez tytułu
|
||||
|
||||
|
||||
+2
@@ -23,6 +23,8 @@ session.copy.hover=Copiar
|
||||
session.copy.prompt=Copiar prompt
|
||||
session.copy.response=Copiar resposta
|
||||
session.copy.copied=Copiado
|
||||
session.queued=Na fila
|
||||
session.queued.remove=Remover mensagem da fila
|
||||
session.tab.new=Nova sessão
|
||||
session.tab.untitled=Sessão sem título
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Копировать
|
||||
session.copy.prompt=Скопировать промпт
|
||||
session.copy.response=Скопировать ответ
|
||||
session.copy.copied=Скопировано
|
||||
session.queued=В очереди
|
||||
session.queued.remove=Удалить сообщение из очереди
|
||||
session.tab.new=Новая сессия
|
||||
session.tab.untitled=Незаголовок сессия
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=คัดลอก
|
||||
session.copy.prompt=คัดลอกพรอมต์
|
||||
session.copy.response=คัดลอกคำตอบ
|
||||
session.copy.copied=คัดลอกแล้ว
|
||||
session.queued=อยู่ในคิว
|
||||
session.queued.remove=ลบข้อความในคิว
|
||||
session.tab.new=เซสชันใหม่
|
||||
session.tab.untitled=เซสชันไม่มีชื่อ
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Kopyala
|
||||
session.copy.prompt=Promptu kopyala
|
||||
session.copy.response=Yanıtı kopyala
|
||||
session.copy.copied=Kopyalandı
|
||||
session.queued=Kuyrukta
|
||||
session.queued.remove=Kuyruktaki mesajı kaldır
|
||||
session.tab.new=Yeni oturum
|
||||
session.tab.untitled=Başlıksız oturum
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ session.copy.hover=Копіювати
|
||||
session.copy.prompt=Скопіювати промпт
|
||||
session.copy.response=Скопіювати відповідь
|
||||
session.copy.copied=Скопійовано
|
||||
session.queued=У черзі
|
||||
session.queued.remove=Видалити повідомлення з черги
|
||||
session.tab.new=Нова сесія
|
||||
session.tab.untitled=Сесія без назви
|
||||
|
||||
|
||||
+2
@@ -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=无标题会话
|
||||
|
||||
|
||||
+2
@@ -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=未命名的工作階段
|
||||
|
||||
|
||||
+40
@@ -102,6 +102,46 @@ class PromptLifecycleTest : SessionControllerTestBase() {
|
||||
assertFalse(message.properties.containsValue("git-changes"))
|
||||
}
|
||||
|
||||
fun `test session queue changed updates queued set`() {
|
||||
val (c, _, modelEvents) = prompted()
|
||||
|
||||
emit(ChatEventDto.SessionQueueChanged("ses_test", listOf("u2")))
|
||||
|
||||
assertEquals(setOf("u2"), c.model.queued)
|
||||
assertModelEvents(
|
||||
"""
|
||||
QueueChanged [u2]
|
||||
""",
|
||||
modelEvents,
|
||||
)
|
||||
|
||||
emit(ChatEventDto.SessionQueueChanged("ses_test", emptyList()))
|
||||
|
||||
assertEquals(emptySet<String>(), c.model.queued)
|
||||
}
|
||||
|
||||
fun `test delete queued message delegates to RPC`() {
|
||||
val (c, _, _) = prompted()
|
||||
|
||||
edt { c.deleteQueuedMessage("u2") }
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes)
|
||||
assertTrue(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" })
|
||||
}
|
||||
|
||||
fun `test delete queued message miss captures error`() {
|
||||
val (c, _, _) = prompted()
|
||||
rpc.messageDeleteResult = false
|
||||
|
||||
edt { c.deleteQueuedMessage("u2") }
|
||||
flush()
|
||||
|
||||
assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes)
|
||||
assertFalse(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" })
|
||||
assertTrue(appRpc.telemetry.any { it.event == "Session Error" && it.properties["context"] == "delete-message" })
|
||||
}
|
||||
|
||||
fun `test PermissionAsked moves state to AwaitingPermission`() {
|
||||
val (m, _, _) = prompted()
|
||||
|
||||
|
||||
+3
-2
@@ -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`() {
|
||||
|
||||
+27
@@ -28,6 +28,7 @@ import ai.kilocode.client.session.views.tool.TaskToolView
|
||||
import ai.kilocode.client.session.views.tool.ToolView
|
||||
import ai.kilocode.client.session.views.todo.TodoWriteView
|
||||
import ai.kilocode.client.ui.DiffStatBadge
|
||||
import ai.kilocode.client.ui.HoverIcon
|
||||
import ai.kilocode.client.ui.layout.Stack
|
||||
import ai.kilocode.rpc.dto.DiffFileDto
|
||||
import ai.kilocode.rpc.dto.MessageDto
|
||||
@@ -129,6 +130,32 @@ class SessionMessageListPanelTest : BasePlatformTestCase() {
|
||||
)
|
||||
}
|
||||
|
||||
fun `test queued turn shows badge and remove action`() {
|
||||
var deleted: String? = null
|
||||
Disposer.dispose(parent)
|
||||
parent = Disposer.newDisposable("test-queued")
|
||||
model = SessionModel()
|
||||
panel = SessionMessageListPanel(model, parent, openFile = openFile, deleteQueued = { deleted = it })
|
||||
model.upsertMessage(msg("u1", "user"))
|
||||
model.updateContent("u1", part("p1", "u1", "text", text = "first"))
|
||||
model.upsertMessage(msg("u2", "user"))
|
||||
model.updateContent("u2", part("p2", "u2", "text", text = "second"))
|
||||
|
||||
model.setQueued(setOf("u2"))
|
||||
|
||||
val u1 = panel.findMessage("u1")!!
|
||||
val u2 = panel.findMessage("u2")!!
|
||||
assertFalse(components(u1).filterIsInstance<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
|
||||
assertTrue(components(u2).filterIsInstance<JBLabel>().any { it.text == KiloBundle.message("session.queued") })
|
||||
|
||||
val remove = components(u2).filterIsInstance<HoverIcon>().single()
|
||||
assertEquals(KiloBundle.message("session.queued.remove"), remove.toolTipText)
|
||||
assertEquals(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR), remove.cursor)
|
||||
remove.doClick()
|
||||
|
||||
assertEquals("u2", deleted)
|
||||
}
|
||||
|
||||
// ------ TurnAdded ------
|
||||
|
||||
fun `test user message creates turn and is findable by message id`() {
|
||||
|
||||
+9
@@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
val aborts = mutableListOf<Pair<String, String>>()
|
||||
val compacts = mutableListOf<Triple<String, String, ModelSelectionDto>>()
|
||||
val reverts = mutableListOf<RevertCall>()
|
||||
val messageDeletes = mutableListOf<MessageDeleteCall>()
|
||||
var messageDeleteResult = true
|
||||
val unreverts = mutableListOf<Pair<String, String>>()
|
||||
val configs = mutableListOf<Pair<String, ConfigUpdateDto>>()
|
||||
val permissionReplies = mutableListOf<Triple<String, String, PermissionReplyDto>>()
|
||||
@@ -117,6 +119,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?)
|
||||
data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto)
|
||||
data class RevertCall(val id: String, val directory: String, val message: String, val part: String?)
|
||||
data class MessageDeleteCall(val id: String, val directory: String, val message: String)
|
||||
|
||||
// --- Implementation ---
|
||||
|
||||
@@ -229,6 +232,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi {
|
||||
reverts.add(RevertCall(id, directory, messageID, partID))
|
||||
}
|
||||
|
||||
override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean {
|
||||
assertNotEdt("deleteMessage")
|
||||
messageDeletes.add(MessageDeleteCall(id, directory, messageID))
|
||||
return messageDeleteResult
|
||||
}
|
||||
|
||||
override suspend fun unrevert(id: String, directory: String) {
|
||||
assertNotEdt("unrevert")
|
||||
unrevertGate?.await()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
kotlin.stdlib.default.dependency=false
|
||||
kilo.jetbrains.version=7.0.10
|
||||
kilo.jetbrains.version=7.0.12-rc.2
|
||||
# When true (default) the JetBrains plugin uses the pinned CLI release from package.json.
|
||||
# Set to false ONLY for local dev: generate the client from local source + bundle the local binary.
|
||||
# false is NOT releasable -- production builds fail unless this is true.
|
||||
|
||||
@@ -33,6 +33,7 @@ object ChatLogSummary {
|
||||
is ChatEventDto.SessionStatusChanged -> event.sessionID
|
||||
is ChatEventDto.SessionUpdated -> event.sessionID
|
||||
is ChatEventDto.SessionIdle -> event.sessionID
|
||||
is ChatEventDto.SessionQueueChanged -> event.sessionID
|
||||
is ChatEventDto.SessionCompacted -> event.sessionID
|
||||
is ChatEventDto.SessionDiffChanged -> event.sessionID
|
||||
is ChatEventDto.TodoUpdated -> event.sessionID
|
||||
@@ -211,6 +212,12 @@ object ChatLogSummary {
|
||||
"evt=session.idle",
|
||||
)
|
||||
|
||||
is ChatEventDto.SessionQueueChanged -> join(
|
||||
sid(event.sessionID),
|
||||
"evt=session.queue.changed",
|
||||
"queued=${event.queued.size}",
|
||||
)
|
||||
|
||||
is ChatEventDto.SessionCompacted -> join(
|
||||
sid(event.sessionID),
|
||||
"evt=session.compacted",
|
||||
|
||||
@@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi<Unit> {
|
||||
/** Revert a session to a prior user message or part. */
|
||||
suspend fun revert(id: String, directory: String, messageID: String, partID: String?)
|
||||
|
||||
/** Delete a single message (used to remove a queued prompt). */
|
||||
suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean
|
||||
|
||||
/** Redo all reverted changes for a session. */
|
||||
suspend fun unrevert(id: String, directory: String)
|
||||
|
||||
|
||||
@@ -254,6 +254,13 @@ sealed class ChatEventDto {
|
||||
val sessionID: String,
|
||||
) : ChatEventDto()
|
||||
|
||||
@Serializable
|
||||
@SerialName("session.queue.changed")
|
||||
data class SessionQueueChanged(
|
||||
val sessionID: String,
|
||||
val queued: List<String> = emptyList(),
|
||||
) : ChatEventDto()
|
||||
|
||||
@Serializable
|
||||
@SerialName("session.compacted")
|
||||
data class SessionCompacted(
|
||||
|
||||
@@ -516,3 +516,10 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
}
|
||||
|
||||
/* Subagent card: the line renders inside the trigger's info row (an align-baseline flex),
|
||||
so force it onto its own full-width line directly under the title/description. */
|
||||
[data-slot="basic-tool-tool-info-main"] > [data-slot="tool-approval-line"] {
|
||||
flex-basis: 100%;
|
||||
padding: 2px 0 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldRenderApprovalInBody } from "./basic-tool"
|
||||
|
||||
describe("shouldRenderApprovalInBody", () => {
|
||||
test("renders in the body by default when an approval exists", () => {
|
||||
expect(shouldRenderApprovalInBody(undefined, true)).toBe(true)
|
||||
expect(shouldRenderApprovalInBody("body", true)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not render when there is no approval", () => {
|
||||
expect(shouldRenderApprovalInBody("body", false)).toBe(false)
|
||||
expect(shouldRenderApprovalInBody(undefined, false)).toBe(false)
|
||||
})
|
||||
|
||||
test("never renders in the body for hidden placement, even with an approval", () => {
|
||||
expect(shouldRenderApprovalInBody("hidden", true)).toBe(false)
|
||||
expect(shouldRenderApprovalInBody("hidden", false)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ export interface BasicToolProps extends BaseProps {
|
||||
tool?: string
|
||||
callID?: string
|
||||
partID?: string
|
||||
approvalPlacement?: "body" | "hidden"
|
||||
}
|
||||
|
||||
type OpenProps = Pick<BasicToolProps, "tool" | "callID" | "partID" | "forceOpen" | "defaultOpen">
|
||||
@@ -19,26 +20,41 @@ export function initialOpen(props: OpenProps) {
|
||||
return props.forceOpen ? true : readToolOpen(toolOpenKey(props), props.defaultOpen)
|
||||
}
|
||||
|
||||
export function useToolApprovalLine() {
|
||||
const approval = useToolApproval()
|
||||
return () => {
|
||||
const value = approval()
|
||||
return value ? <ToolApprovalLine display={value} /> : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether BasicTool should inject the approval line into its body.
|
||||
*/
|
||||
export function shouldRenderApprovalInBody(placement: BasicToolProps["approvalPlacement"], hasApproval: boolean) {
|
||||
return placement !== "hidden" && hasApproval
|
||||
}
|
||||
|
||||
export function BasicTool(props: BasicToolProps) {
|
||||
const key = () => toolOpenKey(props)
|
||||
const initial = () => initialOpen(props)
|
||||
const approval = useToolApproval()
|
||||
const inBody = () => shouldRenderApprovalInBody(props.approvalPlacement, approval() !== undefined)
|
||||
const change = (open: boolean) => {
|
||||
writeToolOpen(key(), open)
|
||||
props.onOpenChange?.(open)
|
||||
}
|
||||
// The "why was this allowed" line lives in the expanded body, above any tool-specific details.
|
||||
const details = () => (
|
||||
<div data-slot="basic-tool-details">
|
||||
<Show when={approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
|
||||
<Show when={inBody() && approval()}>{(value) => <ToolApprovalLine display={value()} />}</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
if (!("children" in props) && !approval()) {
|
||||
if (!("children" in props) && !inBody()) {
|
||||
return <Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} />
|
||||
}
|
||||
return (
|
||||
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails>
|
||||
<Base {...props} defaultOpen={initial()} retainDetails={props.defer} onOpenChange={change} hasDetails={inBody()}>
|
||||
{details()}
|
||||
</Base>
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
import { useDialog } from "../context/dialog"
|
||||
import { type UiI18n, useI18n } from "../context/i18n"
|
||||
import { GenericTool, BasicTool } from "./basic-tool"
|
||||
import { BasicTool, useToolApprovalLine } from "./basic-tool"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Card } from "./card"
|
||||
@@ -1311,7 +1311,14 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
}}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ToolApprovalProvider value={() => resolveToolApproval(meta(), i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string)}>
|
||||
<ToolApprovalProvider
|
||||
value={() =>
|
||||
resolveToolApproval(
|
||||
meta(),
|
||||
i18n.t as (k: string, p?: Record<string, string | number | boolean>) => string,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Dynamic
|
||||
component={render()}
|
||||
input={input()}
|
||||
@@ -1497,9 +1504,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Show when={props.throughput}>
|
||||
{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}
|
||||
</Show>
|
||||
<Show when={props.throughput}>{(el) => <span data-slot="assistant-throughput-inline">{el()}</span>}</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={summary()}>
|
||||
@@ -2172,6 +2177,8 @@ ToolRegistry.register({
|
||||
}, 50)
|
||||
}
|
||||
|
||||
const approvalLine = useToolApprovalLine()
|
||||
|
||||
const trigger = () => (
|
||||
<div data-slot="basic-tool-tool-info-structured">
|
||||
<div data-slot="basic-tool-tool-info-main">
|
||||
@@ -2190,11 +2197,22 @@ ToolRegistry.register({
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
{/* Keep the auto-approve line attached to the subagent card instead of forcing a collapsible body. */}
|
||||
{approvalLine()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return <BasicTool hideDetails icon="task" status={props.status} trigger={trigger()} animated />
|
||||
return (
|
||||
<BasicTool
|
||||
hideDetails
|
||||
approvalPlacement="hidden"
|
||||
icon="task"
|
||||
status={props.status}
|
||||
trigger={trigger()}
|
||||
animated
|
||||
/>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2932,6 +2950,7 @@ ToolRegistry.register({
|
||||
<BasicTool
|
||||
{...props}
|
||||
defaultOpen
|
||||
approvalPlacement="hidden"
|
||||
icon="checklist"
|
||||
trigger={
|
||||
<ToolTriggerRow
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export function changed<T>(current: T | undefined, next: T | undefined, key: (item: T) => string) {
|
||||
if (current === undefined || next === undefined) return current !== next
|
||||
return key(current) !== key(next)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { changed } from "./select-change"
|
||||
|
||||
describe("changed", () => {
|
||||
const key = (item: { value: string }) => item.value
|
||||
|
||||
test("ignores recreated options with the current key", () => {
|
||||
expect(changed({ value: "ollama" }, { value: "ollama" }, key)).toBe(false)
|
||||
})
|
||||
|
||||
test("reports selected and cleared values", () => {
|
||||
expect(changed({ value: "ollama" }, { value: "kilo" }, key)).toBe(true)
|
||||
expect(changed({ value: "ollama" }, undefined, key)).toBe(true)
|
||||
expect(changed(undefined, { value: "ollama" }, key)).toBe(true)
|
||||
expect(changed(undefined, undefined, key)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1 +1,19 @@
|
||||
import { Select as Base, type SelectProps } from "@opencode-ai/ui/select"
|
||||
import type { ButtonProps } from "@opencode-ai/ui/button"
|
||||
import { changed } from "./select-change"
|
||||
|
||||
export * from "@opencode-ai/ui/select"
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
|
||||
const key = (item: T) => (props.value ? props.value(item) : (item as string))
|
||||
|
||||
return (
|
||||
<Base
|
||||
{...props}
|
||||
onSelect={(next) => {
|
||||
if (!changed(props.current, next, key)) return
|
||||
props.onSelect?.(next)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { resolveToolApproval } from "./tool-approval"
|
||||
|
||||
// Echo the key + params so assertions can see which string was chosen without a real dictionary.
|
||||
const t = (key: string, params?: Record<string, string | number | boolean>) =>
|
||||
params
|
||||
? `${key}(${Object.entries(params)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(",")})`
|
||||
: key
|
||||
|
||||
describe("resolveToolApproval", () => {
|
||||
test("returns undefined when there is no approval on the metadata", () => {
|
||||
expect(resolveToolApproval(undefined, t)).toBeUndefined()
|
||||
expect(resolveToolApproval({ other: 1 }, t)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("manual approvals show only the decision, no source or rule", () => {
|
||||
const out = resolveToolApproval({ approval: { source: "manual" } }, t)
|
||||
expect(out).toEqual({
|
||||
approval: { source: "manual" },
|
||||
decision: "ui.approval.manual",
|
||||
source: undefined,
|
||||
rule: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("a specific rule is shown with permission + pattern", () => {
|
||||
const approval = { source: "project" as const, rule: { permission: "bash", pattern: "git *", action: "allow" } }
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.decision).toBe("ui.approval.auto")
|
||||
expect(out?.source).toBe("ui.approval.source.project")
|
||||
expect(out?.rule).toBe("ui.approval.rule(permission=bash,pattern=git *)")
|
||||
})
|
||||
|
||||
test("a per-tool rule with a wildcard pattern still shows the tool name", () => {
|
||||
const approval = {
|
||||
source: "agent" as const,
|
||||
agent: "explore",
|
||||
rule: { permission: "task", pattern: "*", action: "allow" },
|
||||
}
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.rule).toBe("ui.approval.rule(permission=task,pattern=*)")
|
||||
})
|
||||
|
||||
test("the catch-all */* rule is dropped so the line is not noisy for blanket agent defaults", () => {
|
||||
// e.g. the code agent auto-approving `task`/`todowrite` via its "*": "allow" default.
|
||||
const approval = {
|
||||
source: "agent" as const,
|
||||
agent: "code",
|
||||
rule: { permission: "*", pattern: "*", action: "allow" },
|
||||
}
|
||||
const out = resolveToolApproval({ approval }, t)
|
||||
expect(out?.source).toBe("ui.approval.source.agent(agent=code)")
|
||||
expect(out?.rule).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -59,13 +59,18 @@ export function resolveToolApproval(
|
||||
if (approval.source === "manual") return undefined
|
||||
return t(`ui.approval.source.${approval.source}`)
|
||||
}
|
||||
const rule = approval.rule
|
||||
// The catch-all "*"/"*" rule carries no useful detail (it's the blanket allow-everything default),
|
||||
// so drop the "matched `*` rule `*`" fragment and let the source alone explain the approval.
|
||||
const ruleText =
|
||||
rule && !(rule.permission === "*" && rule.pattern === "*")
|
||||
? t("ui.approval.rule", { permission: rule.permission, pattern: rule.pattern })
|
||||
: undefined
|
||||
return {
|
||||
approval,
|
||||
decision: approval.source === "manual" ? t("ui.approval.manual") : t("ui.approval.auto"),
|
||||
source: sourceText(),
|
||||
rule: approval.rule
|
||||
? t("ui.approval.rule", { permission: approval.rule.permission, pattern: approval.rule.pattern })
|
||||
: undefined,
|
||||
rule: ruleText,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +81,7 @@ export function ToolApprovalLine(props: { display: ToolApprovalDisplay }) {
|
||||
<div data-slot="tool-approval-line" data-source={props.display.approval.source}>
|
||||
<span data-slot="tool-approval-decision">{props.display.decision}</span>
|
||||
<Show when={!manual()}>
|
||||
<Show when={props.display.source}>
|
||||
{(text) => <span data-slot="tool-approval-source">{text()}</span>}
|
||||
</Show>
|
||||
<Show when={props.display.source}>{(text) => <span data-slot="tool-approval-source">{text()}</span>}</Show>
|
||||
<Show when={props.display.rule}>{(text) => <span data-slot="tool-approval-rule">{text()}</span>}</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -741,6 +741,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private setSidebarVisible(visible: boolean): void {
|
||||
this.setStreamVisibility(visible)
|
||||
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", visible)
|
||||
if (!visible && this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a WebviewPanel for displaying Kilo in an editor tab. */
|
||||
@@ -981,6 +984,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
return
|
||||
}
|
||||
if (await this.handleModelSelectorExpandedMessage(message)) return
|
||||
this.handleWebviewFocusMessage(message)
|
||||
this.visibleTaskStreams.handle(message)
|
||||
if (await this.handleMemoryMessage(message)) return
|
||||
if (this.handleLegacyMigrationMessage(message)) return
|
||||
@@ -1466,6 +1470,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.webviewMessageDisposable = watchWorkStyleConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
|
||||
}
|
||||
|
||||
private handleWebviewFocusMessage(message: TypedWebviewMessage & { focused?: unknown }): void {
|
||||
if (message.type !== "webviewFocusChanged") return
|
||||
if (this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, message.focused === true)
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
|
||||
return handleEditorAction(message, {
|
||||
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
|
||||
@@ -4559,6 +4570,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* Does NOT kill the server — that's the connection service's job.
|
||||
*/
|
||||
dispose(): void {
|
||||
if (this.opts.focusContext) {
|
||||
void vscode.commands.executeCommand("setContext", this.opts.focusContext, false)
|
||||
}
|
||||
this.unsubscribeRemote?.()
|
||||
this.streams.focus(undefined)
|
||||
this.connectionService.unregisterVisible(this.instanceId)
|
||||
|
||||
@@ -122,7 +122,9 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
|
||||
// Create the provider with shared service
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context, {
|
||||
focusContext: "kilo-code.new.sidebarFocused",
|
||||
})
|
||||
provider.setRemoteService(remoteService)
|
||||
|
||||
// Register the webview view provider for the sidebar.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type KiloProviderOptions = {
|
||||
/** Context key updated from focus events reported by this provider's webview. */
|
||||
focusContext?: string
|
||||
projectDirectory?: string | null
|
||||
platform?: string
|
||||
snapshotInitialization?: "wait"
|
||||
|
||||
@@ -127,6 +127,23 @@ describe("Extension — package.json command sync", () => {
|
||||
when: "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'",
|
||||
})
|
||||
})
|
||||
|
||||
it("scopes agent mode shortcuts to focused Kilo webviews", () => {
|
||||
const bindings = pkg.contributes?.keybindings?.filter(
|
||||
(item: { command: string }) =>
|
||||
item.command === "kilo-code.new.cycleAgentMode" || item.command === "kilo-code.new.cyclePreviousAgentMode",
|
||||
)
|
||||
const when =
|
||||
"kilo-code.new.sidebarFocused || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'"
|
||||
|
||||
expect(bindings).toHaveLength(2)
|
||||
expect(bindings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ command: "kilo-code.new.cycleAgentMode", when }),
|
||||
expect.objectContaining({ command: "kilo-code.new.cyclePreviousAgentMode", when }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
cycleAgent,
|
||||
createDraftAgentSeed,
|
||||
draftAgentSelection,
|
||||
resolveSessionAgent,
|
||||
@@ -81,6 +82,54 @@ describe("resolveSessionAgent", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("cycleAgent", () => {
|
||||
const agents = [
|
||||
{ name: "ask", mode: "primary" },
|
||||
{ name: "plan", mode: "primary" },
|
||||
{ name: "task", mode: "subagent" },
|
||||
{ name: "hidden", mode: "primary", hidden: true },
|
||||
{ name: "code", mode: "primary" },
|
||||
]
|
||||
|
||||
function cycle(current: string, direction: 1 | -1, scope = "pending-1") {
|
||||
const calls: Array<[string, string | undefined]> = []
|
||||
const name = cycleAgent({
|
||||
agents,
|
||||
scope,
|
||||
direction,
|
||||
selected: (id) => {
|
||||
expect(id).toBe(scope)
|
||||
return current
|
||||
},
|
||||
select: (agent, id) => calls.push([agent, id]),
|
||||
})
|
||||
return { name, calls }
|
||||
}
|
||||
|
||||
it("cycles the same pending scope read by the visible selector", () => {
|
||||
expect(cycle("ask", 1)).toEqual({ name: "plan", calls: [["plan", "pending-1"]] })
|
||||
expect(cycle("ask", -1)).toEqual({ name: "code", calls: [["code", "pending-1"]] })
|
||||
})
|
||||
|
||||
it("wraps and starts from the first agent when the selection is unknown", () => {
|
||||
expect(cycle("code", 1).name).toBe("ask")
|
||||
expect(cycle("missing", 1).name).toBe("ask")
|
||||
})
|
||||
|
||||
it("does nothing when there is no alternative", () => {
|
||||
const selected: string[] = []
|
||||
expect(
|
||||
cycleAgent({
|
||||
agents: [{ name: "code" }],
|
||||
direction: 1,
|
||||
selected: () => "code",
|
||||
select: (name) => selected.push(name),
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(selected).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("draftAgentSelection", () => {
|
||||
it("carries a pending agent into a new draft scope", () => {
|
||||
const result = draftAgentSelection({}, "draft-1", "plan")
|
||||
|
||||
@@ -160,6 +160,7 @@ import { buildShortcutCategories } from "./shortcuts"
|
||||
import { tracker } from "./telemetry"
|
||||
import "./agent-manager.css"
|
||||
import "./agent-manager-review.css"
|
||||
import { cycleAgent as cycle } from "../src/context/session-agent"
|
||||
const REVIEW_TAB_ID = "review"
|
||||
|
||||
interface SetupState {
|
||||
@@ -1071,14 +1072,14 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
|
||||
const cycleAgent = (direction: 1 | -1) => {
|
||||
const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
if (available.length <= 1) return
|
||||
const current = session.selectedAgent()
|
||||
const idx = available.findIndex((a) => a.name === current)
|
||||
const raw = idx + direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const agent = available[next]
|
||||
if (agent) session.selectAgent(agent.name)
|
||||
const id = session.currentSessionID() ?? activePendingId()
|
||||
cycle({
|
||||
agents: session.agents(),
|
||||
scope: id,
|
||||
direction,
|
||||
selected: session.selectedAgent,
|
||||
select: session.selectAgent,
|
||||
})
|
||||
}
|
||||
|
||||
const syncRunStatuses = (items: RunStatus[] = []) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ import { FeedbackProvider } from "./context/feedback"
|
||||
import { KiloEmbeddingModelsProvider } from "./context/kilo-embedding-models"
|
||||
import { ImageModelsProvider } from "./context/image-models"
|
||||
import type { Message as SDKMessage, Part as SDKPart } from "@kilocode/sdk/v2"
|
||||
import { cycleAgent as cycle } from "./context/session-agent"
|
||||
import "./styles/chat.css"
|
||||
|
||||
type ViewType = "newTask" | "history" | "profile" | "settings" | "subAgentViewer"
|
||||
@@ -276,14 +277,14 @@ const AppContent: Component = () => {
|
||||
}
|
||||
|
||||
const cycleAgent = (direction: 1 | -1) => {
|
||||
const available = session.agents().filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
if (available.length <= 1) return
|
||||
const current = session.selectedAgent()
|
||||
const idx = available.findIndex((a) => a.name === current)
|
||||
const raw = idx + direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const agent = available[next]
|
||||
if (agent) session.selectAgent(agent.name)
|
||||
const id = session.currentSessionID() ?? tabs?.pending() ?? session.draftSessionID()
|
||||
cycle({
|
||||
agents: session.agents(),
|
||||
scope: id,
|
||||
direction,
|
||||
selected: session.selectedAgent,
|
||||
select: session.selectAgent,
|
||||
})
|
||||
}
|
||||
|
||||
const handleForked = (message: { type?: string; sessionID?: string; forkedFromID?: string }) => {
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { Message } from "../types/messages"
|
||||
|
||||
export function cycleAgent(input: {
|
||||
agents: Array<{ name: string; mode?: string; hidden?: boolean }>
|
||||
scope?: string
|
||||
direction: 1 | -1
|
||||
selected: (scope?: string) => string
|
||||
select: (name: string, scope?: string) => void
|
||||
}) {
|
||||
const available = input.agents.filter((agent) => agent.mode !== "subagent" && !agent.hidden)
|
||||
if (available.length <= 1) return
|
||||
const index = available.findIndex((agent) => agent.name === input.selected(input.scope))
|
||||
const raw = index + input.direction
|
||||
const next = raw < 0 ? available.length - 1 : raw >= available.length ? 0 : raw
|
||||
const name = available[next]?.name
|
||||
if (name) input.select(name, input.scope)
|
||||
return name
|
||||
}
|
||||
|
||||
export function resolveSessionAgent(messages: Message[], names: Set<string>): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const name = messages[i]?.agent?.trim()
|
||||
|
||||
@@ -55,6 +55,10 @@ export const VSCodeProvider: ParentComponent = (props) => {
|
||||
}
|
||||
|
||||
window.addEventListener("message", messageListener)
|
||||
const reportFocus = () => api.postMessage({ type: "webviewFocusChanged", focused: document.hasFocus() })
|
||||
window.addEventListener("focus", reportFocus)
|
||||
window.addEventListener("blur", reportFocus)
|
||||
reportFocus()
|
||||
handlers.add((message) => {
|
||||
if (message?.type === "modelSelectorExpandedLoaded") setExpanded(message.value)
|
||||
})
|
||||
@@ -62,6 +66,8 @@ export const VSCodeProvider: ParentComponent = (props) => {
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("message", messageListener)
|
||||
window.removeEventListener("focus", reportFocus)
|
||||
window.removeEventListener("blur", reportFocus)
|
||||
handlers.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -175,6 +175,11 @@ export interface WebviewReadyRequest {
|
||||
type: "webviewReady"
|
||||
}
|
||||
|
||||
export interface WebviewFocusChangedRequest {
|
||||
type: "webviewFocusChanged"
|
||||
focused: boolean
|
||||
}
|
||||
|
||||
export interface SelectSourceRequest {
|
||||
type: "selectSource"
|
||||
id: string
|
||||
@@ -1257,6 +1262,7 @@ export type WebviewMessage =
|
||||
| CancelLoginRequest
|
||||
| SetOrganizationRequest
|
||||
| WebviewReadyRequest
|
||||
| WebviewFocusChangedRequest
|
||||
| SelectSourceRequest
|
||||
| RequestProvidersMessage
|
||||
| CompactRequest
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { createRequire } from "module"
|
||||
|
||||
export namespace TestCli {
|
||||
export const ENV = "KILO_TEST_CLI_PATH"
|
||||
|
||||
export async function build(root: string, dir: string) {
|
||||
if (path.resolve(process.cwd()) !== path.resolve(root)) {
|
||||
throw new Error(`CLI test bundle must be built from ${root}`)
|
||||
}
|
||||
const { createSolidTransformPlugin } = await import("@opentui/solid/bun-plugin")
|
||||
const entry = "./src/index.ts"
|
||||
const out = path.join(dir, "src/storage")
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entry],
|
||||
outdir: out,
|
||||
target: "bun",
|
||||
format: "esm",
|
||||
conditions: ["browser"],
|
||||
plugins: [createSolidTransformPlugin()],
|
||||
// Keep the native TUI variants dynamic and the memory package singleton shared.
|
||||
external: ["node-gyp", "@opentui/core-*", "@kilocode/kilo-memory", "@kilocode/kilo-memory/*"],
|
||||
naming: { entry: "cli.js", asset: "[name]-[hash].[ext]" },
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build CLI subprocess test bundle")
|
||||
await fs.cp(path.join(root, "migration"), path.join(dir, "migration"), { recursive: true })
|
||||
// Resolve through Node's lookup from the package root: Bun's isolated layout does not
|
||||
// materialize package-level node_modules on every platform (e.g. the Windows runners).
|
||||
const req = createRequire(path.join(root, "package.json"))
|
||||
const core = path.dirname(req.resolve("@opentui/core"))
|
||||
const meta = JSON.parse(await Bun.file(path.join(core, "package.json")).text())
|
||||
const scope = path.join(dir, "node_modules/@opentui")
|
||||
await fs.mkdir(scope, { recursive: true })
|
||||
// Anchor variant lookup to the core package so links stay inside the same install tree.
|
||||
const deps = createRequire(path.join(core, "package.json"))
|
||||
const kind = process.platform === "win32" ? "junction" : "dir"
|
||||
for (const name of Object.keys(meta.optionalDependencies ?? {})) {
|
||||
const target = await (async () => {
|
||||
try {
|
||||
return path.dirname(deps.resolve(name))
|
||||
} catch {
|
||||
// Optional native variant is not installed for this platform.
|
||||
return
|
||||
}
|
||||
})()
|
||||
if (target) await fs.symlink(target, path.join(scope, name.replace("@opentui/", "")), kind)
|
||||
}
|
||||
return path.join(out, "cli.js")
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export namespace TestProfile {
|
||||
"cli/serve/*.test.ts",
|
||||
"kilocode/background-process.test.ts",
|
||||
"kilocode/cli/install-artifact.test.ts",
|
||||
"kilocode/cli/tui/thread.test.ts",
|
||||
"kilocode/core-watcher.test.ts",
|
||||
"kilocode/interactive-terminal.test.ts",
|
||||
"tool/shell.test.ts",
|
||||
|
||||
@@ -9,6 +9,7 @@ import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { TestProfile } from "./kilocode/test-profile"
|
||||
import { TestShard } from "./kilocode/test-shard"
|
||||
import { TestCli } from "./kilocode/test-cli"
|
||||
import { remove } from "../test/kilocode/cleanup"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "..")
|
||||
@@ -178,12 +179,33 @@ type Result = {
|
||||
attempts: number
|
||||
}
|
||||
|
||||
type Proc = ReturnType<typeof Bun.spawn>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const xmldir = ci ? path.join(os.tmpdir(), `opencode-junit-${process.pid}`) : ""
|
||||
if (ci) await fs.mkdir(xmldir, { recursive: true })
|
||||
const supplied = process.env[TestCli.ENV]
|
||||
const binprefix = path.join(root, ".artifacts", "test-cli-")
|
||||
const built = supplied
|
||||
? { binary: supplied, dir: undefined }
|
||||
: await (async () => {
|
||||
await fs.mkdir(path.dirname(binprefix), { recursive: true })
|
||||
const dir = await fs.mkdtemp(binprefix)
|
||||
return { binary: await TestCli.build(root, dir), dir }
|
||||
})()
|
||||
|
||||
async function cleanBinary() {
|
||||
if (!built.dir) return
|
||||
const expected = path.dirname(binprefix)
|
||||
const valid =
|
||||
path.dirname(built.dir) === expected && path.basename(built.dir).startsWith(path.basename(binprefix))
|
||||
if (!valid) throw new Error(`Refusing to remove unexpected test CLI directory: ${built.dir}`)
|
||||
// The generated directory contains the bundle, emitted assets, and copied migrations.
|
||||
await fs.rm(built.dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
const counter = { done: 0 }
|
||||
const pad = String(files.length).length
|
||||
@@ -200,6 +222,74 @@ const marks = {
|
||||
} as const
|
||||
const legend = `Legend: ${marks.pass}=pass ${marks.retry}=pass-after-retry ${marks.fail}=fail ${marks.timeout}=timeout`
|
||||
|
||||
function drain(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
const promise = (async () => {
|
||||
let text = ""
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) return text + decoder.decode()
|
||||
text += decoder.decode(chunk.value, { stream: true })
|
||||
}
|
||||
})()
|
||||
return {
|
||||
promise,
|
||||
close: () => reader.cancel().catch(() => undefined),
|
||||
}
|
||||
}
|
||||
|
||||
async function signal(proc: Proc, sig: "SIGTERM" | "SIGKILL") {
|
||||
if (process.platform === "win32") {
|
||||
const args = ["/pid", String(proc.pid), "/T"]
|
||||
if (sig === "SIGKILL") args.push("/F")
|
||||
const kill = Bun.spawn(["taskkill", ...args], {
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
windowsHide: true,
|
||||
})
|
||||
await kill.exited
|
||||
return
|
||||
}
|
||||
|
||||
const tree = Bun.spawn(["ps", "-axo", "pid=,ppid="], {
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
})
|
||||
const [code, text] = await Promise.all([tree.exited, new Response(tree.stdout).text()])
|
||||
const rows = code === 0 ? text.trim().split("\n") : []
|
||||
const children = new Map<number, number[]>()
|
||||
for (const row of rows) {
|
||||
const [pid, parent] = row.trim().split(/\s+/).map(Number)
|
||||
if (!Number.isSafeInteger(pid) || !Number.isSafeInteger(parent)) continue
|
||||
const list = children.get(parent) ?? []
|
||||
list.push(pid)
|
||||
children.set(parent, list)
|
||||
}
|
||||
const collect = (pid: number): number[] => (children.get(pid) ?? []).flatMap((child) => [...collect(child), child])
|
||||
for (const pid of [...collect(proc.pid), proc.pid]) {
|
||||
for (const target of [-pid, pid]) {
|
||||
try {
|
||||
process.kill(target, sig)
|
||||
} catch (error) {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ESRCH") continue
|
||||
// A kill failure (e.g. EPERM in a sandboxed runner) must not take down the whole run.
|
||||
console.error(`warn: failed to signal ${target} with ${sig}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function terminate(proc: Proc) {
|
||||
if (proc.exitCode !== null) return
|
||||
await signal(proc, "SIGTERM")
|
||||
const exited = Symbol("exited")
|
||||
const result = await Promise.race([proc.exited.then(() => exited), Bun.sleep(2_000)])
|
||||
if (result === exited) return
|
||||
await signal(proc, "SIGKILL")
|
||||
await Promise.race([proc.exited, Bun.sleep(2_000)])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Run a single test file
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -218,24 +308,36 @@ async function run(file: string): Promise<Result> {
|
||||
|
||||
const proc = Bun.spawn(cmd, {
|
||||
cwd: root,
|
||||
env: { ...process.env, [TestCli.ENV]: built.binary },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
detached: process.platform !== "win32",
|
||||
})
|
||||
active.set(proc.pid, proc)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
killed.value = true
|
||||
proc.kill()
|
||||
}, deadline)
|
||||
|
||||
const stdout = new Response(proc.stdout).text()
|
||||
const stderr = new Response(proc.stderr).text()
|
||||
const code = await proc.exited.finally(async () => {
|
||||
clearTimeout(timer)
|
||||
const stdout = drain(proc.stdout)
|
||||
const stderr = drain(proc.stderr)
|
||||
const code = await Promise.race([
|
||||
proc.exited.then((value) => ({ timedout: false, value })),
|
||||
Bun.sleep(deadline).then(() => ({ timedout: true, value: -1 })),
|
||||
]).then(async (result) => {
|
||||
if (result.timedout) {
|
||||
killed.value = true
|
||||
await terminate(proc)
|
||||
}
|
||||
await finish(proc)
|
||||
return result.timedout ? (proc.exitCode ?? result.value) : result.value
|
||||
})
|
||||
const output = await Promise.race([
|
||||
Promise.all([stdout.promise, stderr.promise]).then((value) => ({ closed: true, value })),
|
||||
Bun.sleep(2_000).then(() => ({ closed: false, value: ["", ""] as [string, string] })),
|
||||
]).then(async (result) => {
|
||||
if (result.closed) return result.value
|
||||
await signal(proc, "SIGKILL")
|
||||
await Promise.all([stdout.close(), stderr.close()])
|
||||
return Promise.all([stdout.promise, stderr.promise])
|
||||
})
|
||||
const output = await Promise.all([stdout, stderr])
|
||||
|
||||
return {
|
||||
file,
|
||||
@@ -254,7 +356,7 @@ function finish(proc: ReturnType<typeof Bun.spawn>) {
|
||||
if (found) return found
|
||||
|
||||
const promise = (async () => {
|
||||
await proc.exited
|
||||
await Promise.race([proc.exited, Bun.sleep(2_000)])
|
||||
await cleanup(proc.pid)
|
||||
})().finally(() => {
|
||||
active.delete(proc.pid)
|
||||
@@ -269,10 +371,9 @@ function shutdown(code: number) {
|
||||
stopping.promise = (async () => {
|
||||
stopped.value = true
|
||||
const children = [...active.values()]
|
||||
for (const proc of children) {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL")
|
||||
}
|
||||
await Promise.all(children.map(terminate))
|
||||
await Promise.all(children.map(finish))
|
||||
await cleanBinary()
|
||||
process.exit(code)
|
||||
})()
|
||||
return stopping.promise
|
||||
@@ -450,6 +551,8 @@ if (ci) {
|
||||
})
|
||||
}
|
||||
|
||||
await cleanBinary()
|
||||
|
||||
process.exit(failures.length > 0 ? 1 : 0)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -283,6 +283,9 @@ export const TuiThreadCommand = cmd({
|
||||
}
|
||||
process.once("SIGHUP", () => shutdownAndExit({ reason: "signal", signal: "SIGHUP", code: 129 }))
|
||||
process.once("SIGTERM", () => shutdownAndExit({ reason: "signal", signal: "SIGTERM", code: 143 }))
|
||||
// kilocode_change - external kill -INT takes the same graceful path as SIGHUP/SIGTERM.
|
||||
// Interactive Ctrl-C in the TUI is a raw-mode keypress, not a signal.
|
||||
process.once("SIGINT", () => shutdownAndExit({ reason: "signal", signal: "SIGINT", code: 130 }))
|
||||
// In some terminal/tab-close paths the parent shell is terminated without
|
||||
// forwarding a signal to this process, leaving the TUI orphaned. Detect
|
||||
// parent PID re-parenting and exit explicitly.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// kilocode_change - new file
|
||||
// Pure shutdown sequence for the embedded TUI worker. Extracted so unit tests can
|
||||
// assert drain → dispose → stopServer ordering without loading worker.ts side effects.
|
||||
export function createWorkerShutdown(input: {
|
||||
drain: () => Promise<void>
|
||||
dispose: () => Promise<void>
|
||||
stopServer: () => Promise<void>
|
||||
}) {
|
||||
return async () => {
|
||||
await input.drain()
|
||||
await input.dispose()
|
||||
await input.stopServer()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecy
|
||||
import { KiloLog } from "@/kilocode/log" // kilocode_change
|
||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change
|
||||
import { createWorkerRemoteExit } from "@/kilocode/cli/cmd/tui/remote-exit-worker" // kilocode_change
|
||||
import { createWorkerShutdown } from "@/cli/tui/worker-shutdown" // kilocode_change
|
||||
import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change
|
||||
|
||||
ensureProcessMetadata("worker") // kilocode_change - retain worker role and parent run correlation
|
||||
await KiloLog.init() // kilocode_change - keep compatibility logs off the TUI terminal
|
||||
@@ -25,6 +27,15 @@ GlobalBus.on("event", (event) => {
|
||||
|
||||
let server: Awaited<ReturnType<typeof Server.listen>> | undefined
|
||||
const remoteExit = createWorkerRemoteExit(Rpc.emit) // kilocode_change
|
||||
// kilocode_change start - drain ingest before dispose so GlobalBus/remote stay live
|
||||
const runShutdown = createWorkerShutdown({
|
||||
drain: () => KiloSessions.drainIngestForShutdown(),
|
||||
dispose: () => InstanceRuntime.disposeAllInstances(),
|
||||
stopServer: async () => {
|
||||
if (server) await server.stop(true)
|
||||
},
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
export const rpc = {
|
||||
// kilocode_change start - worker lifecycle hooks for remote exit
|
||||
@@ -78,8 +89,7 @@ export const rpc = {
|
||||
},
|
||||
async shutdown() {
|
||||
remoteExit.shutdown() // kilocode_change
|
||||
await InstanceRuntime.disposeAllInstances()
|
||||
if (server) await server.stop(true)
|
||||
await runShutdown() // kilocode_change - drain → dispose → stopServer
|
||||
// kilocode_change start - Clear the Rpc message channel so the worker's event loop can drain and
|
||||
// exit naturally. Without this, the active onmessage handle keeps the
|
||||
// worker alive even after all async work is done.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Once-per-process guard around the session ingest shutdown drain.
|
||||
// Overlapping shutdown paths (worker RPC, KiloShutdown, serve signals) must not double-POST.
|
||||
// The guarded call never rejects: a drain failure must not block the remaining shutdown
|
||||
// sequence (disposeAllInstances / server.stop). Failures are logged once via the optional
|
||||
// onError callback; later callers share the same resolved promise (no retry).
|
||||
export namespace IngestDrain {
|
||||
export function create(run: () => Promise<void>, onError?: (err: unknown) => void) {
|
||||
let done: Promise<void> | undefined
|
||||
return () => {
|
||||
if (!done) {
|
||||
done = run().catch((err) => {
|
||||
onError?.(err)
|
||||
})
|
||||
}
|
||||
return done
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,8 @@ export namespace IngestQueue {
|
||||
// To avoid spamming the server, we coalesce updates and flush at most once per ~1s per session.
|
||||
//
|
||||
// `due` is the earliest time we should flush; it is also used to respect backoff when retries are
|
||||
// active. A later `due` always wins over an earlier one.
|
||||
// active. A later `due` always wins over an earlier one for non-terminal batches. Terminal batches
|
||||
// (`session_close`) may pull the flush earlier.
|
||||
const queue = new Map<string, { timeout: Timer; due: number; data: Map<string, Data> }>()
|
||||
|
||||
// Per-session retry state.
|
||||
@@ -94,6 +95,18 @@ export namespace IngestQueue {
|
||||
// - Store `until` so sync() can avoid scheduling a flush before backoff expires
|
||||
const retry = new Map<string, { count: number; until: number }>()
|
||||
|
||||
// In-flight flush promises. flush() deletes the queue entry before I/O, so an empty queue is not
|
||||
// quiescence — drain must join these too.
|
||||
const inflight = new Set<Promise<void>>()
|
||||
|
||||
// Last successfully resolved client and per-session share. Drain falls back to these when
|
||||
// getClient/getShare fail during teardown (e.g. authValid HTTP check).
|
||||
let cached: Client | undefined
|
||||
const shares = new Map<string, Share>()
|
||||
|
||||
// Shutdown mode: one attempt per item, no re-enqueue, use cached client/share on resolution failure.
|
||||
let shutting = false
|
||||
|
||||
const now = options.now ?? (() => Date.now())
|
||||
const set = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms))
|
||||
const clear = options.clearTimeout ?? ((timer) => clearTimeout(timer))
|
||||
@@ -154,12 +167,12 @@ export namespace IngestQueue {
|
||||
return models.length > 0 ? `model:${models}` : ulid()
|
||||
}
|
||||
|
||||
function schedule(sessionId: string, due: number, data: Map<string, Data>) {
|
||||
function schedule(sessionId: string, due: number, data: Map<string, Data>, terminal = false) {
|
||||
const existing = queue.get(sessionId)
|
||||
if (existing) {
|
||||
// Don't reschedule if an earlier flush is already planned.
|
||||
// We only move the flush later (e.g., to respect backoff).
|
||||
if (existing.due >= due) return
|
||||
// Non-terminal: only move the flush later (e.g., to respect backoff).
|
||||
// Terminal (`session_close`): may pull the flush earlier so the tail is not left behind.
|
||||
if (!terminal && existing.due >= due) return
|
||||
clear(existing.timeout)
|
||||
}
|
||||
|
||||
@@ -170,7 +183,7 @@ export namespace IngestQueue {
|
||||
queue.set(sessionId, { timeout, due, data })
|
||||
}
|
||||
|
||||
function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number) {
|
||||
function enqueue(sessionId: string, items: Data[], mode: "overwrite" | "fill", due: number, terminal = false) {
|
||||
const existing = queue.get(sessionId)
|
||||
if (existing) {
|
||||
for (const item of items) {
|
||||
@@ -180,7 +193,7 @@ export namespace IngestQueue {
|
||||
if (mode === "fill" && existing.data.has(k)) continue
|
||||
existing.data.set(k, item)
|
||||
}
|
||||
schedule(sessionId, due, existing.data)
|
||||
schedule(sessionId, due, existing.data, terminal)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -189,7 +202,12 @@ export namespace IngestQueue {
|
||||
data.set(key(item), item)
|
||||
}
|
||||
|
||||
schedule(sessionId, due, data)
|
||||
schedule(sessionId, due, data, terminal)
|
||||
}
|
||||
|
||||
function requeue(sessionId: string, items: Data[], delay: number) {
|
||||
if (shutting) return
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
}
|
||||
|
||||
async function flush(sessionId: string) {
|
||||
@@ -204,13 +222,49 @@ export namespace IngestQueue {
|
||||
queue.delete(sessionId)
|
||||
|
||||
const items = Array.from(queued.data.values())
|
||||
|
||||
const done = run(sessionId, items)
|
||||
inflight.add(done)
|
||||
try {
|
||||
const share = await options.getShare(sessionId).catch(() => undefined)
|
||||
if (!share) return
|
||||
await done
|
||||
} finally {
|
||||
inflight.delete(done)
|
||||
}
|
||||
}
|
||||
|
||||
const client = await options.getClient()
|
||||
if (!client) return
|
||||
async function resolveShare(sessionId: string) {
|
||||
const fresh = await options.getShare(sessionId).catch(() => undefined)
|
||||
if (fresh) shares.set(sessionId, fresh)
|
||||
return fresh ?? (shutting ? shares.get(sessionId) : undefined)
|
||||
}
|
||||
|
||||
async function resolveClient() {
|
||||
// Preserve normal-path throw → outer catch logging; only swallow during shutdown so the
|
||||
// cached client can be used.
|
||||
const fresh = await options.getClient().catch((error) => {
|
||||
if (!shutting) throw error
|
||||
return undefined
|
||||
})
|
||||
if (fresh) cached = fresh
|
||||
return fresh ?? (shutting ? cached : undefined)
|
||||
}
|
||||
|
||||
async function run(sessionId: string, items: Data[]) {
|
||||
try {
|
||||
const share = await resolveShare(sessionId)
|
||||
if (!share) {
|
||||
if (shutting) {
|
||||
options.log.error("ingest drain skipped", { sessionId, reason: "no share" })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const client = await resolveClient()
|
||||
if (!client) {
|
||||
if (shutting) {
|
||||
options.log.error("ingest drain skipped", { sessionId, reason: "no client" })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (options.log.info) {
|
||||
const types = items.map((d) => d.type).join(",")
|
||||
@@ -233,6 +287,12 @@ export namespace IngestQueue {
|
||||
|
||||
if (!response) {
|
||||
// Network failures are assumed transient; retry with backoff and a small budget.
|
||||
// Shutdown: one attempt only — log and drop so the process can exit.
|
||||
if (shutting) {
|
||||
options.log.error("share sync failed", { sessionId, error: "network", shutdown: true })
|
||||
return
|
||||
}
|
||||
|
||||
const count = (retry.get(sessionId)?.count ?? 0) + 1
|
||||
if (count > 6) {
|
||||
options.log.error("share sync failed", { sessionId, error: "retry budget exceeded" })
|
||||
@@ -243,7 +303,7 @@ export namespace IngestQueue {
|
||||
const delay = backoff(count)
|
||||
retry.set(sessionId, { count, until: now() + delay })
|
||||
options.log.error("share sync failed", { sessionId, error: "network", attempt: count, retryInMs: delay })
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
requeue(sessionId, items, delay)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,6 +336,16 @@ export namespace IngestQueue {
|
||||
return
|
||||
}
|
||||
|
||||
if (shutting) {
|
||||
options.log.error("share sync failed", {
|
||||
sessionId,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
shutdown: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const current = retry.get(sessionId)
|
||||
const count = (current?.count ?? 0) + 1
|
||||
if (count > 6) {
|
||||
@@ -293,7 +363,7 @@ export namespace IngestQueue {
|
||||
attempt: count,
|
||||
retryInMs: delay,
|
||||
})
|
||||
enqueue(sessionId, items, "fill", now() + delay)
|
||||
requeue(sessionId, items, delay)
|
||||
} catch (error) {
|
||||
options.log.error("share sync failed", { sessionId, error })
|
||||
}
|
||||
@@ -305,6 +375,7 @@ export namespace IngestQueue {
|
||||
// - Otherwise, merge into the pending queue entry.
|
||||
// The next flush is scheduled ~1s after the first queued event (throttled), but never earlier
|
||||
// than the current backoff window (if retries are active).
|
||||
// - A batch containing session_close is terminal: flush ASAP (respecting backoff only).
|
||||
const client = await options.getClient()
|
||||
if (!client) return
|
||||
|
||||
@@ -313,15 +384,54 @@ export namespace IngestQueue {
|
||||
options.log.info("ingest sync", { sessionId, types })
|
||||
}
|
||||
|
||||
const terminal = data.some((item) => item.type === "session_close")
|
||||
const until = retry.get(sessionId)?.until ?? 0
|
||||
const base = queue.get(sessionId)?.due ?? now() + 1000
|
||||
// Terminal batches do not inherit the open debounce window — only backoff.
|
||||
const base = terminal ? now() : (queue.get(sessionId)?.due ?? now() + 1000)
|
||||
const due = Math.max(base, until)
|
||||
enqueue(sessionId, data, "overwrite", due)
|
||||
enqueue(sessionId, data, "overwrite", due, terminal)
|
||||
}
|
||||
|
||||
async function drain(bound = 3_000) {
|
||||
// Shutdown drain: one bounded attempt per pending session, join in-flight flushes, no re-enqueue.
|
||||
shutting = true
|
||||
const deadline = now() + bound
|
||||
|
||||
for (const sessionId of Array.from(queue.keys())) {
|
||||
void flush(sessionId)
|
||||
}
|
||||
|
||||
while (queue.size > 0 || inflight.size > 0) {
|
||||
for (const sessionId of Array.from(queue.keys())) {
|
||||
void flush(sessionId)
|
||||
}
|
||||
|
||||
if (now() >= deadline) {
|
||||
options.log.error("ingest drain timed out", {
|
||||
queue: queue.size,
|
||||
inflight: inflight.size,
|
||||
bound,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (inflight.size === 0) continue
|
||||
|
||||
const pending = Array.from(inflight)
|
||||
const left = Math.max(0, deadline - now())
|
||||
let timer: Timer | undefined
|
||||
const timeout = new Promise<void>((resolve) => {
|
||||
timer = set(() => resolve(), left)
|
||||
})
|
||||
await Promise.race([Promise.allSettled(pending), timeout])
|
||||
if (timer !== undefined) clear(timer)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sync,
|
||||
flush,
|
||||
drain,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Auth } from "@/auth"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { IngestQueue } from "@/kilo-sessions/ingest-queue"
|
||||
import { IngestDrain } from "@/kilo-sessions/ingest-drain"
|
||||
import { clearInFlightCache, withInFlightCache } from "@/kilo-sessions/inflight-cache"
|
||||
import type * as SDK from "@kilocode/sdk/v2"
|
||||
import z from "zod"
|
||||
@@ -222,6 +223,18 @@ export namespace KiloSessions {
|
||||
},
|
||||
})
|
||||
|
||||
// Process-level once-guard: overlapping shutdown paths must not double-POST.
|
||||
// Do not call from per-directory instance finalizers — wrong granularity.
|
||||
// Never-reject: serve/worker await this unguarded before dispose/stop.
|
||||
const drainIngest = IngestDrain.create(
|
||||
() => ingest.drain(),
|
||||
(err) => log.warn("ingest drain failed", { err }),
|
||||
)
|
||||
|
||||
export async function drainIngestForShutdown() {
|
||||
await drainIngest()
|
||||
}
|
||||
|
||||
const remoteEnabled = process.env["KILO_REMOTE"] === "1"
|
||||
let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined
|
||||
let enabling: Promise<void> | undefined
|
||||
|
||||
@@ -24,6 +24,24 @@ import { KiloLog } from "@/kilocode/log"
|
||||
|
||||
const log = Log.create({ service: "kilocode.cli" })
|
||||
|
||||
// Process-level ingest drain for non-TUI commands (`kilo run`, etc.).
|
||||
// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order.
|
||||
// Registered at setup load time (not inside shutdown()) so the task is always present.
|
||||
// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load
|
||||
// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits
|
||||
// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import
|
||||
// returns the same in-process module singleton, so the drained queue is the one that
|
||||
// received events. Task try/catch covers dynamic-import failure outside the shared drain
|
||||
// guard; the drain itself never rejects.
|
||||
KiloShutdown.register(async () => {
|
||||
try {
|
||||
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
|
||||
await KiloSessions.drainIngestForShutdown()
|
||||
} catch (err) {
|
||||
log.warn("ingest drain failed", { err })
|
||||
}
|
||||
})
|
||||
|
||||
// All Kilo-specific CLI customization lives here so the shared upstream entrypoint
|
||||
// (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers.
|
||||
// This keeps index.ts close to upstream and reduces merge conflicts on every sync.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Node } from "web-tree-sitter"
|
||||
|
||||
// tree-sitter-powershell drops commands containing a bare `--` into ERROR nodes
|
||||
// instead of command nodes, so the shell permission scanner collected zero
|
||||
// patterns and skipped the check entirely (Kilo-Org/kilocode#12326). Recover
|
||||
// the failed command text from ERROR nodes, and fail closed with the raw input
|
||||
// whenever the parse has errors and nothing else was recovered, so every
|
||||
// executed command yields at least one permission pattern. The raw fallback
|
||||
// also covers ERROR chunks without a command_name descendant (for example
|
||||
// PowerShell backtick escapes), which can still contain runnable text.
|
||||
export function unparsed(root: Node, commands: number): string[] {
|
||||
if (!root.hasError && commands > 0) return []
|
||||
const failed = root
|
||||
.descendantsOfType("ERROR")
|
||||
.filter((node): node is Node => Boolean(node))
|
||||
.filter((node) => node.descendantsOfType("command_name").length > 0)
|
||||
.map((node) => node.text.trim())
|
||||
.filter((text) => text.length > 0)
|
||||
if (failed.length > 0) return failed
|
||||
const raw = root.text.trim()
|
||||
return raw ? [raw] : []
|
||||
}
|
||||
@@ -616,15 +616,17 @@ function anthropicOpus47OrLater(apiId: string) {
|
||||
return major > 4 || (major === 4 && minor >= 7)
|
||||
}
|
||||
|
||||
// kilocode_change start - fable and sonnet-5 models are adaptive thinking models like opus-4.7/4.8
|
||||
// kilocode_change start - Claude 5+ models are adaptive thinking models like opus-4.7/4.8
|
||||
function anthropicClaude5(apiId: string) {
|
||||
const id = apiId.toLowerCase()
|
||||
return id.includes("fable") || /sonnet[.-]5/.test(id)
|
||||
if (id.includes("fable")) return true
|
||||
const version = /(?:opus|sonnet)[.-](\d+)(?:[.@-]|$)|claude-(\d+)(?:[.-]\d+)?-(?:opus|sonnet)(?:[.@-]|$)/.exec(id)
|
||||
return Number(version?.[1] ?? version?.[2]) >= 5
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
|
||||
// kilocode_change start - include Claude 5+ models
|
||||
if (anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId)) {
|
||||
return ["low", "medium", "high", "xhigh", "max"]
|
||||
}
|
||||
@@ -640,7 +642,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null {
|
||||
}
|
||||
|
||||
function anthropicOmitsThinking(apiId: string) {
|
||||
return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's fable/sonnet-5 aliases
|
||||
return anthropicOpus47OrLater(apiId) || anthropicClaude5(apiId) // kilocode_change - include Kilo's Claude 5 aliases
|
||||
}
|
||||
|
||||
function googleThinkingLevelEfforts(apiId: string) {
|
||||
@@ -976,7 +978,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
if (adaptiveEfforts) {
|
||||
let efforts = [...adaptiveEfforts]
|
||||
if (model.providerID === "github-copilot") {
|
||||
// kilocode_change start - treat opus-4.8 and fable like opus-4.7
|
||||
// kilocode_change start - include Claude 5+ models
|
||||
if (
|
||||
model.api.id.includes("opus-4.7") ||
|
||||
model.api.id.includes("opus-4.8") ||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Plugin } from "@/plugin"
|
||||
import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change
|
||||
import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change
|
||||
import { heredocs } from "@/kilocode/tool/shell-heredoc" // kilocode_change
|
||||
import { unparsed } from "@/kilocode/tool/shell-unparsed" // kilocode_change
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { ShellPrompt, type Parameters } from "./shell/prompt"
|
||||
@@ -404,6 +405,14 @@ export const ShellPermission = Effect.gen(function* () {
|
||||
}
|
||||
}
|
||||
|
||||
// kilocode_change start - fail closed on commands the grammar failed to parse (#12326)
|
||||
const lost = unparsed(root, nodes.length)
|
||||
if (lost.length > 0) scan.access = "unknown"
|
||||
for (const pattern of lost) {
|
||||
scan.patterns.add(pattern)
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
return scan
|
||||
})
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import { KiloShutdown } from "../../src/kilocode/cli/shutdown"
|
||||
|
||||
const calls: string[] = []
|
||||
const timeouts: Array<number | undefined> = []
|
||||
let err: unknown
|
||||
let drainErr: unknown
|
||||
let drainCalls = 0
|
||||
let exit: string | number | null | undefined
|
||||
|
||||
mock.module("@opencode-ai/core/global", () => ({
|
||||
@@ -67,6 +70,16 @@ mock.module("@/kilocode/session-export", () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/kilo-sessions/kilo-sessions", () => ({
|
||||
KiloSessions: {
|
||||
async drainIngestForShutdown() {
|
||||
drainCalls += 1
|
||||
calls.push("drain")
|
||||
if (drainErr) throw drainErr
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/kilocode/help-command", () => ({
|
||||
createHelpCommand: () => ({ command: "help", handler() {} }),
|
||||
}))
|
||||
@@ -94,11 +107,34 @@ for (const path of [
|
||||
}))
|
||||
}
|
||||
|
||||
/** Same mock body as the kilo-sessions module mock used by setup.ts's drain task. */
|
||||
function registerDrain() {
|
||||
KiloShutdown.register(async () => {
|
||||
drainCalls += 1
|
||||
calls.push("drain")
|
||||
if (drainErr) throw drainErr
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a drain task for this test only. Clears any leftover registry entries first
|
||||
* (setup.ts's one-time module-scope registration, or a prior test) so assertions do not
|
||||
* depend on declaration order or on whether an earlier test already ran KiloShutdown.run().
|
||||
*/
|
||||
async function installDrain() {
|
||||
await KiloShutdown.run()
|
||||
calls.length = 0
|
||||
drainCalls = 0
|
||||
registerDrain()
|
||||
}
|
||||
|
||||
describe("KiloCli.shutdown", () => {
|
||||
beforeEach(() => {
|
||||
calls.length = 0
|
||||
timeouts.length = 0
|
||||
err = undefined
|
||||
drainErr = undefined
|
||||
drainCalls = 0
|
||||
exit = process.exitCode
|
||||
process.exitCode = undefined
|
||||
})
|
||||
@@ -107,26 +143,44 @@ describe("KiloCli.shutdown", () => {
|
||||
process.exitCode = exit
|
||||
})
|
||||
|
||||
test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => {
|
||||
err = "Timeout while shutting down PostHog. Some events may not have been sent."
|
||||
// Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it.
|
||||
// Only this test pins that one-time module-scope registration (and the drain-before-dispose
|
||||
// ordering it enables). Later tests call installDrain() so they do not rely on order.
|
||||
test("rejects drain without blocking dispose", async () => {
|
||||
drainErr = new Error("ingest drain failed")
|
||||
process.exitCode = 0
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
|
||||
await expect(KiloCli.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(drainCalls).toBe(1)
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test("keeps telemetry shutdown timeout best-effort and still disposes instances", async () => {
|
||||
err = "Timeout while shutting down PostHog. Some events may not have been sent."
|
||||
process.exitCode = 0
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
await installDrain()
|
||||
|
||||
await expect(KiloCli.shutdown()).resolves.toBeUndefined()
|
||||
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test("preserves failing command exit status", async () => {
|
||||
process.exitCode = 1
|
||||
const { KiloCli } = await import("../../src/kilocode/cli/setup")
|
||||
await installDrain()
|
||||
|
||||
await KiloCli.shutdown()
|
||||
|
||||
expect(timeouts).toEqual([2000])
|
||||
expect(calls).toEqual(["track:1", "session", "telemetry", "dispose"])
|
||||
expect(calls).toEqual(["track:1", "session", "telemetry", "drain", "dispose"])
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { spawn, type Exit } from "@opencode-ai/core/pty/driver"
|
||||
import { sanitizedProcessEnv } from "@opencode-ai/core/util/opencode-process"
|
||||
import { tmpdir } from "../../../fixture/fixture"
|
||||
import {
|
||||
embeddedRemoteExitClient,
|
||||
@@ -41,6 +44,81 @@ describe("kilo tui thread", () => {
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test(
|
||||
"starts the TUI from a directory without OpenTUI dependencies",
|
||||
async () => {
|
||||
await using root = await tmpdir()
|
||||
const state = { text: "", exit: undefined as Exit | undefined }
|
||||
const ready = Promise.withResolvers<void>()
|
||||
const stopped = Promise.withResolvers<void>()
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--conditions=browser",
|
||||
`--preload=${fileURLToPath(import.meta.resolve("@opentui/solid/preload"))}`,
|
||||
path.resolve(import.meta.dir, "../../../../src/index.ts"),
|
||||
],
|
||||
{
|
||||
name: "xterm-256color",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: root.path,
|
||||
env: sanitizedProcessEnv({
|
||||
HOME: root.path,
|
||||
XDG_CONFIG_HOME: path.join(root.path, ".config"),
|
||||
XDG_DATA_HOME: path.join(root.path, ".local/share"),
|
||||
XDG_STATE_HOME: path.join(root.path, ".local/state"),
|
||||
XDG_CACHE_HOME: path.join(root.path, ".cache"),
|
||||
KILO_TEST_HOME: root.path,
|
||||
KILO_CONFIG_CONTENT: "{}",
|
||||
KILO_AUTH_CONTENT: "{}",
|
||||
KILO_DISABLE_PROJECT_CONFIG: "1",
|
||||
KILO_DISABLE_AUTOUPDATE: "1",
|
||||
KILO_DISABLE_MODELS_FETCH: "1",
|
||||
KILO_DISABLE_TERMINAL_TITLE: "0",
|
||||
KILO_DEV_CWD: "",
|
||||
KILO_PURE: "1",
|
||||
KILO_NO_DAEMON: "1",
|
||||
TERM: "xterm-256color",
|
||||
}),
|
||||
},
|
||||
)
|
||||
const data = proc.onData((chunk) => {
|
||||
state.text = (state.text + chunk).slice(-20_000)
|
||||
if (state.text.includes("TUI worker error")) {
|
||||
ready.reject(new Error(`TUI worker failed during startup:\n${state.text}`))
|
||||
return
|
||||
}
|
||||
// The title is emitted only after the worker-backed TUI reaches its rendered app.
|
||||
if (state.text.includes("Kilo CLI")) ready.resolve()
|
||||
})
|
||||
const exit = proc.onExit((event) => {
|
||||
state.exit = event
|
||||
stopped.resolve()
|
||||
ready.reject(
|
||||
new Error(
|
||||
`TUI exited before rendering (code ${event.exitCode}, signal ${event.signal ?? "none"}):\n${state.text}`,
|
||||
),
|
||||
)
|
||||
})
|
||||
const timer = setTimeout(() => {
|
||||
ready.reject(new Error(`Timed out waiting for the TUI to render:\n${state.text}`))
|
||||
}, 30_000)
|
||||
|
||||
try {
|
||||
await ready.promise
|
||||
expect(state.text).toContain("Kilo CLI")
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
data.dispose()
|
||||
if (!state.exit) proc.kill()
|
||||
await stopped.promise
|
||||
exit.dispose()
|
||||
}
|
||||
},
|
||||
45_000,
|
||||
)
|
||||
|
||||
test("ignores stale PWD after cwd is changed by a process wrapper", async () => {
|
||||
await using root = await tmpdir()
|
||||
const pkg = path.join(root.path, "packages", "opencode")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user