diff --git a/.changeset/jetbrains-queued-prompts.md b/.changeset/jetbrains-queued-prompts.md new file mode 100644 index 0000000000..b22d8c8353 --- /dev/null +++ b/.changeset/jetbrains-queued-prompts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Allow sending prompts while a session is busy and show queued prompts with a remove action. diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md new file mode 100644 index 0000000000..5a019637c3 --- /dev/null +++ b/.changeset/opencode-v1-17-5-to-v1-17-9.md @@ -0,0 +1,30 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Changes from opencode v1.17.5 to v1.17.9 upstream: + +- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities. +- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port. +- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start. +- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions. +- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense) +- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang) +- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties. +- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx) +- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled. +- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure. +- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly. +- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run. +- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first. +- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes. +- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China) +- Core Bugfixes: Pass configured custom headers to Copilot model requests. +- Core Improvements: MCP servers can now receive the current workspace as a client root. +- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps. +- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte) +- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective. +- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version. +- TUI Bugfixes: Only show the background subagent shortcut when the server supports it. +- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown. diff --git a/.changeset/pwsh-permission-fail-closed.md b/.changeset/pwsh-permission-fail-closed.md new file mode 100644 index 0000000000..cb140f33cd --- /dev/null +++ b/.changeset/pwsh-permission-fail-closed.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- `. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check. diff --git a/.changeset/stalled-provider-first-byte.md b/.changeset/stalled-provider-first-byte.md new file mode 100644 index 0000000000..2d11921fd6 --- /dev/null +++ b/.changeset/stalled-provider-first-byte.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected. diff --git a/.github/docs-sync/edit.mjs b/.github/docs-sync/edit.mjs index 7da66bca64..777c3a2929 100644 --- a/.github/docs-sync/edit.mjs +++ b/.github/docs-sync/edit.mjs @@ -6,20 +6,22 @@ * Batching bounds each `kilo run` context (a replay window can yield dozens * of docs-worthy PRs with large diffs). Each batch gets its own CLI session * and writes its own summary file; results are merged into - * docs-sync-out/edit-summary.json. A batch that fails is skipped with a - * warning — its PRs show up in the rolling PR body as skipped, so nothing - * fails silently. + * docs-sync-out/edit-summary.json. A batch that fails or is deferred by the + * wall-clock budget is recorded as action "pending" so the watermark holds + * back and the next run re-collects those PRs. * * Env: EDIT_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (set by workflow; read natively by the kilo provider). + * Budgets: EDIT_BUDGET_MINUTES (default 50), EDIT_BATCH_TIMEOUT_MINUTES (default 15). + * Test hook: DOCS_SYNC_BACKOFF_MS replaces every retry wait when set. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" +import { backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const BATCH_SIZE = 5 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" export const SUMMARY_FILE = ".docs-sync-summary.json" @@ -28,6 +30,10 @@ const basePrompt = fs.readFileSync(path.join(HERE, "edit-prompt.md"), "utf8") const model = process.env.EDIT_MODEL if (!model) throw new Error("EDIT_MODEL is required") +const EDIT_BUDGET_MINUTES = Number(process.env.EDIT_BUDGET_MINUTES) || 50 +const EDIT_BATCH_TIMEOUT_MINUTES = Number(process.env.EDIT_BATCH_TIMEOUT_MINUTES) || 15 +const BATCH_TIMEOUT_MS = EDIT_BATCH_TIMEOUT_MINUTES * 60 * 1000 + const worthy = JSON.parse(fs.readFileSync(`${OUT_DIR}/worthy.json`, "utf8")) const triage = JSON.parse(fs.readFileSync(`${OUT_DIR}/triage.json`, "utf8")) const priority = new Map(triage.map((e) => [e.url, e])) @@ -36,7 +42,18 @@ const ordered = [...worthy].sort((a, b) => { return (rank[priority.get(a.url)?.priority] ?? 1) - (rank[priority.get(b.url)?.priority] ?? 1) }) -function editBatch(batch, index) { +/** @type {Map} url → pending cause for failed/deferred batches */ +const pendingCauses = new Map() + +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function editBatch(batch, index, budgetDeadline) { const batchFile = `${OUT_DIR}/edit-batch-${index}.json` const triageFile = `${OUT_DIR}/edit-batch-triage-${index}.json` const summaryFile = `${OUT_DIR}/edit-summary-${index}.json` @@ -54,31 +71,59 @@ function editBatch(batch, index) { Batch specifics for this run: the PRs to handle are in the attached ${batchFile} (full details) and ${triageFile} (triage verdicts). Handle ONLY the PRs in these batch files. When finished, write your per-PR results in the summary JSON format described above to the file \`${summaryFile}\` (path relative to the repository root).` + let lastCause = "edit pass failed" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - execFileSync( - "kilo", - ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], - // stdout streams live to the Actions log; stderr is piped so failure - // warnings can include the tail of the actual CLI error. - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 25 * 60 * 1000, stdio: ["ignore", "inherit", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + lastCause = `edit budget exhausted before batch ${index} attempt ${attempt} (${Math.ceil(left / 1000)}s left, need ${EDIT_BATCH_TIMEOUT_MINUTES}m)` + console.warn( + `batch ${index}: stopping retries — remaining budget cannot fit another ${EDIT_BATCH_TIMEOUT_MINUTES}m attempt`, ) - if (fs.existsSync(summaryFile)) return true - // Tolerate the agent dropping the docs-sync-out/ prefix. - const alt = path.basename(summaryFile) - if (fs.existsSync(alt)) { - fs.renameSync(alt, summaryFile) - return true + break + } + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--variant", "high", "--dir", process.cwd(), "-f", batchFile, "-f", triageFile], + timeoutMs: Math.min(BATCH_TIMEOUT_MS, left), + streamStdout: true, + label: `edit batch ${index} attempt ${attempt}`, + }) + + if (fs.existsSync(summaryFile)) return true + // Tolerate the agent dropping the docs-sync-out/ prefix. + const alt = path.basename(summaryFile) + if (fs.existsSync(alt)) { + fs.renameSync(alt, summaryFile) + return true + } + + // Exit 0 is not success: missing summary is a failure logged WITH the + // captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `edit batch ${index}: ${cause}` + console.warn( + `batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + // Skip the wait when the remaining budget cannot fit another attempt. + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= BATCH_TIMEOUT_MS) { + console.warn(`batch ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `batch ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) } - console.warn(`batch ${index} attempt ${attempt}: summary file ${summaryFile} not produced`) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`batch ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) } } - console.warn(`::warning::edit batch ${index} failed after ${ATTEMPTS} attempts; ${batch.length} PRs skipped`) + + console.warn(`::warning::edit batch ${index} failed after up to ${ATTEMPTS} attempts; ${batch.length} PRs pending`) + for (const d of batch) pendingCauses.set(d.url, lastCause) return false } @@ -88,12 +133,34 @@ for (let i = 0; i < ordered.length; i += BATCH_SIZE) { } console.log(`editing docs for ${ordered.length} PRs in ${batches.length} batches of up to ${BATCH_SIZE}`) +const budgetDeadline = deadline(EDIT_BUDGET_MINUTES) +let deferredFrom = -1 + for (let i = 0; i < batches.length; i++) { - editBatch(batches[i], i) + const left = remainingMs(budgetDeadline) + if (left < BATCH_TIMEOUT_MS) { + deferredFrom = i + const deferredPrs = batches.slice(i).reduce((n, b) => n + b.length, 0) + console.warn( + `stopping edit pass before batch ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${EDIT_BATCH_TIMEOUT_MINUTES}m batch; deferring ${deferredPrs} PRs`, + ) + const cause = `edit budget exhausted before batch ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < batches.length; j++) { + for (const d of batches[j]) pendingCauses.set(d.url, cause) + } + break + } + editBatch(batches[i], i, budgetDeadline) +} + +if (deferredFrom >= 0) { + console.warn( + `edit pass deferred ${batches.slice(deferredFrom).reduce((n, b) => n + b.length, 0)} PRs due to wall-clock budget`, + ) } // Merge batch summaries. Coverage: every worthy PR gets an entry so the PR -// body accounts for it; failed batches show up as skipped. +// body accounts for it; failed/deferred batches show up as pending (not skipped). const merged = [] const seen = new Set() for (let i = 0; i < batches.length; i++) { @@ -108,15 +175,24 @@ for (let i = 0; i < batches.length; i++) { const url = String(e?.url ?? "") if (!url.startsWith("http") || seen.has(url)) continue seen.add(url) - merged.push({ pr: Number(e.pr) || 0, url, action: String(e.action ?? "skipped"), reason: String(e.reason ?? "") }) + merged.push({ + pr: Number(e.pr) || 0, + url, + action: String(e.action ?? "skipped"), + reason: String(e.reason ?? ""), + }) } } for (const d of ordered) { if (seen.has(d.url)) continue - merged.push({ pr: d.number, url: d.url, action: "skipped", reason: "edit pass failed or timed out for this PR" }) + const cause = pendingCauses.get(d.url) || "edit pass failed or timed out for this PR" + merged.push({ pr: d.number, url: d.url, action: "pending", reason: cause }) } // upsert-pr.mjs consumes the merged summary from the repo root; the file is // removed there before committing so it never lands in the docs PR. fs.writeFileSync(SUMMARY_FILE, JSON.stringify(merged, null, 2)) -console.log(`edit pass complete: ${merged.filter((e) => e.action !== "skipped").length} changed, ${merged.filter((e) => e.action === "skipped").length} skipped`) +const changed = merged.filter((e) => e.action !== "skipped" && e.action !== "pending").length +const skipped = merged.filter((e) => e.action === "skipped").length +const pending = merged.filter((e) => e.action === "pending").length +console.log(`edit pass complete: ${changed} changed, ${skipped} skipped, ${pending} pending`) diff --git a/.github/docs-sync/lib.mjs b/.github/docs-sync/lib.mjs index 56dfb7b922..67c13354d8 100644 --- a/.github/docs-sync/lib.mjs +++ b/.github/docs-sync/lib.mjs @@ -6,6 +6,7 @@ * gh CLI. */ +import { spawnSync } from "node:child_process" import fs from "node:fs" const API = "https://api.github.com" @@ -111,3 +112,97 @@ export function appendSummary(markdown) { const summary = process.env.GITHUB_STEP_SUMMARY if (summary) fs.appendFileSync(summary, markdown + "\n") } + +/** + * Absolute deadline timestamp (ms since epoch) for a wall-clock budget. + * Used by triage/edit to stop before the job timeout rather than silently + * truncating. + */ +export function deadline(minutes) { + return Date.now() + Number(minutes) * 60 * 1000 +} + +/** Remaining milliseconds until a deadline; never negative. */ +export function remainingMs(deadlineMs) { + return Math.max(0, Number(deadlineMs) - Date.now()) +} + +/** + * Backoff schedule between kilo-run attempts. Production waits 60s then 300s + * (observed outage lasted ~11 min; batch 8 recovered on attempt 2). When + * DOCS_SYNC_BACKOFF_MS is set it replaces EVERY wait (`0` disables waiting); + * the workflow never sets it — only selftests do. + */ +export function backoffMsForAttempt(attempt) { + // attempt is 1-based; wait happens after attempt N before attempt N+1. + const override = process.env.DOCS_SYNC_BACKOFF_MS + if (override !== undefined && override !== "") { + const n = Number(override) + return Number.isFinite(n) && n >= 0 ? n : 0 + } + // After attempt 1 → 60s; after attempt 2 → 300s; nothing after the last. + if (attempt === 1) return 60_000 + if (attempt === 2) return 300_000 + return 0 +} + +/** + * Blocking sleep used between kilo-run retries. Prefer this over async sleep + * so edit/triage stay synchronous around spawnSync. + */ +export function sleepSync(ms) { + const n = Number(ms) + if (!Number.isFinite(n) || n <= 0) return + const end = Date.now() + n + // Atomics.wait is the portable Node sync sleep (no busy loop). + const sab = new SharedArrayBuffer(4) + const view = new Int32Array(sab) + while (Date.now() < end) { + const left = end - Date.now() + if (left <= 0) break + Atomics.wait(view, 0, 0, Math.min(left, 2_147_483_647)) + } +} + +const STDERR_TAIL_LINES = 20 +const STDERR_TAIL_CHARS = 4_000 + +function tailText(text, { lines = STDERR_TAIL_LINES, chars = STDERR_TAIL_CHARS } = {}) { + const s = String(text ?? "").trim() + if (!s) return "" + const lastLines = s.split("\n").slice(-lines).join("\n") + return lastLines.length > chars ? lastLines.slice(-chars) : lastLines +} + +/** + * Run `kilo` via spawnSync so stderr is always recoverable — including when + * the child exits 0 after writing a diagnostic (execFileSync cannot return + * piped stderr on exit 0; that path lost every diagnostic on run 30122603016). + * + * streamStdout:true → inherit fd 1 (edit live log); false → capture stdout + * (triage parses it). stderr is always buffered. + */ +export function runKilo({ args, timeoutMs, streamStdout = false, label = "kilo" }) { + const result = spawnSync("kilo", args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + timeout: timeoutMs, + stdio: ["ignore", streamStdout ? "inherit" : "pipe", "pipe"], + }) + + const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT") + const exitCode = + typeof result.status === "number" ? result.status : timedOut ? null : result.status === null ? null : result.status + const stderrTail = tailText(result.stderr) + const stdout = streamStdout ? "" : String(result.stdout ?? "") + // ok is "process finished without OS-level failure". Callers still treat a + // missing summary / unparseable output as failure even when ok is true — + // exit 0 is not success for the docs-sync bot. + const ok = !result.error && result.status === 0 + + if (result.error && !timedOut) { + console.warn(`${label}: spawn error: ${result.error.message}`) + } + + return { ok, stdout, stderrTail, exitCode, timedOut } +} diff --git a/.github/docs-sync/prepare-branch.mjs b/.github/docs-sync/prepare-branch.mjs index 2b57932c44..2710832ccf 100644 --- a/.github/docs-sync/prepare-branch.mjs +++ b/.github/docs-sync/prepare-branch.mjs @@ -6,56 +6,100 @@ * origin/main (preserves any human commits on the branch) * - otherwise -> fresh branch from origin/main (bot force-pushes later) * - * Outputs: branch, mode (update|fresh), pr_number (empty when fresh). + * Outputs: branch, mode (update|fresh|conflict), pr_number (empty when fresh). */ import { execFileSync } from "node:child_process" +import { pathToFileURL } from "node:url" import { api, appendOutput, repo, searchIssues } from "./lib.mjs" export const DEFAULT_BRANCH = "docs/auto-sync" -const git = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() +const defaultGit = (args) => execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"] }).toString().trim() -const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) - -let mode = "fresh" -let prNumber = "" -let branch = DEFAULT_BRANCH - -if (prs.length > 0) { - const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) - branch = pr.head?.ref ?? DEFAULT_BRANCH - prNumber = String(pr.number) - git(["fetch", "origin", "main", branch]) - git(["checkout", branch]) +/** + * Merge origin/main into the current branch. On a genuine conflict, abort the + * merge, switch to a dated fallback branch from origin/main, and return + * mode=conflict so human commits on the rolling branch stay untouched. Any + * other merge failure (missing identity, corrupt ref, fetch issues) is + * rethrown so the job fails loudly. + */ +export function mergeOrFallback({ branch, git = defaultGit }) { try { git(["merge", "origin/main", "--no-edit"]) - mode = "update" - } catch { + return { branch, mode: "update" } + } catch (err) { + // Conflict ⇔ unmerged index entries (or MERGE_HEAD still present). + // Identity failures and similar abort before a merge is started, so + // merge --abort would itself fail — those must rethrow. + let unmerged = "" + try { + unmerged = git(["ls-files", "--unmerged"]) + } catch { + // ls-files itself failing is not a conflict signal + } + let mergeInProgress = false + try { + git(["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + mergeInProgress = true + } catch { + mergeInProgress = false + } + const isConflict = unmerged.length > 0 || mergeInProgress + if (!isConflict) throw err + console.warn(`merge of origin/main into ${branch} conflicted.`) - console.warn("Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.") + console.warn( + "Leaving the conflicted branch untouched so human commits are preserved; continuing on a fresh dated branch.", + ) git(["merge", "--abort"]) - branch = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + const fallback = `${DEFAULT_BRANCH}-${new Date().toISOString().slice(0, 10)}` + try { + git(["fetch", "origin", `+refs/heads/${fallback}:refs/remotes/origin/${fallback}`]) + } catch { + console.log(`dated branch ${fallback} does not exist on origin yet; will create it on push`) + } + git(["checkout", "-B", fallback, "origin/main"]) + return { branch: fallback, mode: "conflict" } + } +} + +async function main() { + const git = defaultGit + const prs = await searchIssues(`repo:${repo()} is:pr is:open label:auto-docs sort:created-desc`, { maxPages: 1 }) + + let mode = "fresh" + let prNumber = "" + let branch = DEFAULT_BRANCH + + if (prs.length > 0) { + const pr = await api(`/repos/${repo()}/pulls/${prs[0].number}`) + branch = pr.head?.ref ?? DEFAULT_BRANCH + prNumber = String(pr.number) + git(["fetch", "origin", "main", branch]) + git(["checkout", branch]) + ;({ branch, mode } = mergeOrFallback({ branch, git })) + } else { + // Keep the remote-tracking ref current so the later --force-with-lease + // push (stale branch left over from a merged/closed PR) is safe. try { git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) } catch { - console.log(`dated branch ${branch} does not exist on origin yet; will create it on push`) + console.log(`branch ${branch} does not exist on origin yet; will create it on push`) } git(["checkout", "-B", branch, "origin/main"]) - mode = "conflict" } -} else { - // Keep the remote-tracking ref current so the later --force-with-lease - // push (stale branch left over from a merged/closed PR) is safe. - try { - git(["fetch", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`]) - } catch { - console.log(`branch ${branch} does not exist on origin yet; will create it on push`) - } - git(["checkout", "-B", branch, "origin/main"]) + + appendOutput("branch", branch) + appendOutput("mode", mode) + appendOutput("pr_number", prNumber) + console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) } -appendOutput("branch", branch) -appendOutput("mode", mode) -appendOutput("pr_number", prNumber) -console.log(`branch ${branch} ready (mode=${mode}, pr=${prNumber || "none"})`) +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/.github/docs-sync/selftest.mjs b/.github/docs-sync/selftest.mjs new file mode 100644 index 0000000000..fe2cb1faec --- /dev/null +++ b/.github/docs-sync/selftest.mjs @@ -0,0 +1,964 @@ +// kilocode_change - new file + +/** + * Offline self-check for the docs-sync failure paths (S4). + * Plain node:assert, no network, no LLM, no new dependency. + * Run: node .github/docs-sync/selftest.mjs + */ + +import assert from "node:assert/strict" +import { execFileSync, spawnSync } from "node:child_process" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import { mergeOrFallback, DEFAULT_BRANCH } from "./prepare-branch.mjs" +import { applyCap } from "./watermark.mjs" +import { + computeUncovered, + computeProcessedThrough, + routeRows, + dropLegacySkipped, + noDiffReport, + renderBody, + extractSectionRows, +} from "./upsert-pr.mjs" + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const EDIT_SCRIPT = path.join(HERE, "edit.mjs") +const TRIAGE_SCRIPT = path.join(HERE, "triage.mjs") +const COLLECT_SCRIPT = path.join(HERE, "collect.mjs") + +const temps = [] + +function mktemp(prefix) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)) + temps.push(dir) + return dir +} + +function cleanup() { + for (const dir of temps.splice(0)) { + try { + fs.rmSync(dir, { recursive: true, force: true }) + } catch { + // best-effort + } + } +} + +function writeExecutable(filePath, body) { + fs.writeFileSync(filePath, body, { mode: 0o755 }) +} + +function makeStubKiloDir({ mode, callLog, stderrText = "event stream disconnected" }) { + const dir = mktemp("docs-sync-kilo-") + const kiloPath = path.join(dir, "kilo") + // mode: "stderr-exit0" | "record" | "partial-triage" | "mixed-triage" + const script = `#!/usr/bin/env node +const fs = require("node:fs"); +const path = require("node:path"); +const mode = ${JSON.stringify(mode)}; +const callLog = ${JSON.stringify(callLog ?? "")}; +const stderrText = ${JSON.stringify(stderrText)}; +if (callLog) { + fs.appendFileSync(callLog, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }) + "\\n"); +} +if (mode === "stderr-exit0") { + process.stderr.write(stderrText + "\\n"); + process.exit(0); +} +if (mode === "record") { + process.stderr.write("recorded\\n"); + process.exit(0); +} +// Parse -f chunk/batch file from args for triage stubs +const args = process.argv.slice(2); +const fIdx = args.indexOf("-f"); +const fileArg = fIdx >= 0 ? args[fIdx + 1] : null; +let chunk = []; +if (fileArg && fs.existsSync(fileArg)) { + try { chunk = JSON.parse(fs.readFileSync(fileArg, "utf8")); } catch { chunk = []; } +} +if (mode === "partial-triage") { + // Classify only a proper subset (first URL) of the chunk. + const owned = chunk.slice(0, Math.max(0, chunk.length - 1)); + const entries = owned.map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: false, + reason: "genuine not worthy", + target_sections: [], + priority: "medium", + })); + if (entries.length === 0 && chunk.length > 0) { + // single-PR chunk: still leave one missing by emitting empty-ish foreign-only + process.stdout.write("[]\\n"); + } else { + process.stdout.write(JSON.stringify(entries) + "\\n"); + } + process.exit(0); +} +if (mode === "mixed-triage") { + // Half docs_worthy true, half fail (no output for second half — but we return + // only some entries so backfill marks the rest pending). Actually: return + // docs_worthy:true for first half of chunk URLs so worthy > 0. + const half = Math.ceil(chunk.length / 2); + const entries = chunk.slice(0, half).map((d) => ({ + pr: d.number, + url: d.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })); + process.stdout.write(JSON.stringify(entries) + "\\n"); + process.exit(0); +} +process.stderr.write("unknown stub mode\\n"); +process.exit(1); +` + writeExecutable(kiloPath, script) + return dir +} + +function gitIn(cwd, args, env = {}) { + return execFileSync("git", args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).toString().trim() +} + +function makeGitRunner(cwd, env = {}) { + return (args) => gitIn(cwd, args, env) +} + +function initRepoWithIdentity(dir) { + gitIn(dir, ["init", "-b", "main"]) + gitIn(dir, ["config", "user.name", "docs-sync-selftest"]) + gitIn(dir, ["config", "user.email", "docs-sync-selftest@example.com"]) + gitIn(dir, ["config", "commit.gpgsign", "false"]) +} + +// --------------------------------------------------------------------------- +// Case 1 — Defect A: mergeOrFallback +// --------------------------------------------------------------------------- +function case1_mergeOrFallback() { + console.log("case 1: Defect A (mergeOrFallback)") + + // 1a — identity configured + clean merge → mode=update + { + const dir = mktemp("docs-sync-merge-clean-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "on branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch commit"]) + // Advance main without conflict + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "on main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main advance"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "update") + assert.equal(result.branch, DEFAULT_BRANCH) + // merge brought c.txt in + assert.ok(fs.existsSync(path.join(dir, "c.txt"))) + } + + // 1b — genuine conflict → mode=conflict, abort succeeds, original branch untouched + { + const dir = mktemp("docs-sync-merge-conflict-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "conflict.txt"), "base\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + const baseSha = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "branch side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "branch edit"]) + const branchShaBefore = gitIn(dir, ["rev-parse", "HEAD"]) + + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "conflict.txt"), "main side\n") + gitIn(dir, ["add", "conflict.txt"]) + gitIn(dir, ["commit", "-m", "main edit"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + const result = mergeOrFallback({ branch: DEFAULT_BRANCH, git: makeGitRunner(dir) }) + assert.equal(result.mode, "conflict") + assert.ok(result.branch.startsWith(`${DEFAULT_BRANCH}-`)) + // Original rolling branch tip unchanged + const branchShaAfter = gitIn(dir, ["rev-parse", DEFAULT_BRANCH]) + assert.equal(branchShaAfter, branchShaBefore) + // No merge in progress + let mergeHead = true + try { + gitIn(dir, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]) + } catch { + mergeHead = false + } + assert.equal(mergeHead, false) + void baseSha + } + + // 1c — identity-less / non-conflict merge failure → throws (does not fake conflict) + { + const dir = mktemp("docs-sync-merge-noid-") + initRepoWithIdentity(dir) + fs.writeFileSync(path.join(dir, "a.txt"), "base\n") + gitIn(dir, ["add", "a.txt"]) + gitIn(dir, ["commit", "-m", "base"]) + gitIn(dir, ["checkout", "-b", DEFAULT_BRANCH]) + fs.writeFileSync(path.join(dir, "b.txt"), "branch\n") + gitIn(dir, ["add", "b.txt"]) + gitIn(dir, ["commit", "-m", "branch"]) + gitIn(dir, ["checkout", "main"]) + fs.writeFileSync(path.join(dir, "c.txt"), "main\n") + gitIn(dir, ["add", "c.txt"]) + gitIn(dir, ["commit", "-m", "main"]) + gitIn(dir, ["update-ref", "refs/remotes/origin/main", "main"]) + gitIn(dir, ["checkout", DEFAULT_BRANCH]) + + // Strip identity so merge cannot create a commit + gitIn(dir, ["config", "--unset", "user.name"]) + gitIn(dir, ["config", "--unset", "user.email"]) + + const env = { + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_SYSTEM: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + } + const git = (args) => + execFileSync("git", ["-c", "user.useConfigOnly=true", ...args], { + cwd: dir, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }).toString().trim() + + assert.throws(() => mergeOrFallback({ branch: DEFAULT_BRANCH, git }), (err) => { + // Must throw the original merge error, not a merge --abort failure + const msg = String(err?.stderr ?? err?.message ?? err) + assert.ok(!/no merge to abort/i.test(msg), `should not reach merge --abort: ${msg}`) + return true + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers to run edit.mjs / triage.mjs as child processes +// --------------------------------------------------------------------------- +function setupEditCwd(worthy, triage) { + const cwd = mktemp("docs-sync-edit-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "worthy.json"), JSON.stringify(worthy, null, 2)) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "triage.json"), JSON.stringify(triage, null, 2)) + return cwd +} + +function setupTriageCwd(digest) { + const cwd = mktemp("docs-sync-triage-") + fs.mkdirSync(path.join(cwd, "docs-sync-out"), { recursive: true }) + fs.writeFileSync(path.join(cwd, "docs-sync-out", "digest.json"), JSON.stringify(digest, null, 2)) + return cwd +} + +function runNodeScript(scriptPath, { cwd, env = {}, kiloDir }) { + const pathEnv = [kiloDir, process.env.PATH].filter(Boolean).join(path.delimiter) + const result = spawnSync(process.execPath, [scriptPath], { + cwd, + env: { + ...process.env, + ...env, + PATH: pathEnv, + DOCS_SYNC_BACKOFF_MS: env.DOCS_SYNC_BACKOFF_MS ?? "0", + }, + encoding: "utf8", + timeout: 60_000, + }) + return { + status: result.status, + stdout: String(result.stdout ?? ""), + stderr: String(result.stderr ?? ""), + output: `${result.stdout ?? ""}${result.stderr ?? ""}`, + error: result.error, + } +} + +function samplePr(n, { merged_at, repo = "Kilo-Org/cloud" } = {}) { + return { + repo, + number: n, + title: `feat: sample ${n}`, + url: `https://github.com/${repo}/pull/${n}`, + author: "dev", + merged_at: merged_at ?? "2026-07-20T12:00:00.000Z", + labels: [], + body: "body", + files: [], + files_total: 1, + patch_excerpt: "", + } +} + +// --------------------------------------------------------------------------- +// Case 2 — Defect B: edit.mjs with stub kilo (exit 0 + stderr) +// --------------------------------------------------------------------------- +function case2_defectB() { + console.log("case 2: Defect B (edit.mjs stderr-on-exit-0)") + + const prs = [1, 2, 3, 4, 5].map((n) => samplePr(n)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: ["overview"], + priority: "high", + })) + const cwd = setupEditCwd(worthy, triage) + const stderrText = "event stream disconnected DIAG-CASE2" + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText }) + + const started = Date.now() + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + // Enough budget for 3 attempts × tiny timeout + EDIT_BUDGET_MINUTES: "5", + EDIT_BATCH_TIMEOUT_MINUTES: "1", + }, + }) + const elapsed = Date.now() - started + + assert.equal(result.status, 0, `edit.mjs exit: ${result.output}`) + // Backoff collapsed — 3 attempts without 60s+300s waits + assert.ok(elapsed < 15_000, `backoff should collapse with DOCS_SYNC_BACKOFF_MS=0; elapsed=${elapsed}ms`) + + assert.match(result.output, /stderr tail:/) + assert.match(result.output, /DIAG-CASE2|event stream disconnected/) + assert.match(result.output, /attempt 1/) + assert.match(result.output, /attempt 2/) + // 3 attempts + assert.match(result.output, /attempt 3|failed after up to 3 attempts/) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.equal(summary.length, 5) + for (const e of summary) { + assert.equal(e.action, "pending", `expected pending, got ${JSON.stringify(e)}`) + } + + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 5) + for (const u of uncovered) { + assert.ok(u.reason, "uncovered reason present") + } +} + +// --------------------------------------------------------------------------- +// Case 3 — watermark invariant +// --------------------------------------------------------------------------- +function case3_watermark() { + console.log("case 3: watermark invariant") + + const now = "2026-07-27T12:00:00.000Z" + const nowMs = Date.parse(now) + + const prA = samplePr(10, { merged_at: "2026-07-20T10:00:00.000Z" }) + const prB = samplePr(11, { merged_at: "2026-07-22T15:30:00.000Z" }) + const prC = samplePr(12, { merged_at: "2026-07-25T08:00:00.000Z" }) + const digest = [prA, prB, prC] + + // all covered → processed-through === now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated packages/kilo-docs/pages/x.md", reason: "" }, + { pr: 11, url: prB.url, action: "skipped", reason: "already documented" }, + ] + const triage = [ + { pr: 10, url: prA.url, docs_worthy: true, pending: false, reason: "ok" }, + { pr: 11, url: prB.url, docs_worthy: true, pending: false, reason: "ok" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 0) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, now) + } + + // one uncovered → merged_at − 1 ms, strictly < now + { + const worthy = [prA, prB] + const summary = [ + { pr: 10, url: prA.url, action: "updated x", reason: "" }, + { pr: 11, url: prB.url, action: "pending", reason: "edit batch 0: exit 0" }, + ] + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, prB.url) + const through = computeProcessedThrough({ uncovered, digest, now }) + const expected = new Date(Date.parse(prB.merged_at) - 1).toISOString() + assert.equal(through, expected) + assert.ok(Date.parse(through) < nowMs) + } + + // several uncovered → earliest merge time wins + { + const worthy = [prA, prB, prC] + const summary = [ + { pr: 10, url: prA.url, action: "pending", reason: "fail" }, + { pr: 12, url: prC.url, action: "pending", reason: "fail" }, + ] + // prB missing from summary entirely + const uncovered = computeUncovered({ worthy, summary, triage: [] }) + assert.ok(uncovered.length >= 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + // earliest among A, B, C that are uncovered — A is earliest + const times = uncovered + .map((u) => digest.find((d) => d.url === u.url)?.merged_at) + .filter(Boolean) + .map((t) => Date.parse(t)) + const earliest = Math.min(...times) + assert.equal(through, new Date(earliest - 1).toISOString()) + } + + // summary missing/truncated while worthy non-empty → every worthy URL held back + { + const worthy = [prA, prB] + const uncovered = computeUncovered({ worthy, summary: [], triage: [] }) + assert.equal(uncovered.length, 2) + const through = computeProcessedThrough({ uncovered, digest, now }) + assert.equal(through, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + } + + // noDiffReport three arms + { + const uncovered = [{ url: prA.url, reason: "edit batch failed" }] + const arm1 = noDiffReport({ uncovered, sinceOverride: true }) + assert.ok(arm1.summary.includes(prA.url)) + assert.ok(arm1.warning, "override + uncovered → warning present") + + const arm2 = noDiffReport({ uncovered: [], sinceOverride: true }) + assert.equal(arm2.warning, null, "override + empty uncovered → warning absent") + + const arm3 = noDiffReport({ uncovered, sinceOverride: false }) + assert.equal(arm3.warning, null, "scheduled + uncovered → warning absent") + } + + // triage pending:true backfill rows land in uncovered (consumption) + { + const triage = [ + { + pr: 99, + url: "https://github.com/Kilo-Org/cloud/pull/99", + docs_worthy: false, + pending: true, + reason: "not classified by triage", + }, + ] + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 1) + assert.equal(uncovered[0].url, triage[0].url) + const through = computeProcessedThrough({ + uncovered, + digest: [{ url: triage[0].url, merged_at: "2026-07-21T00:00:00.000Z" }], + now, + }) + assert.equal(through, new Date(Date.parse("2026-07-21T00:00:00.000Z") - 1).toISOString()) + } + + // fallback field (post-plan repair) + { + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/50", reason: "missing" }] + const fallback = "2026-07-17T00:00:00.000Z" + // unresolved merged_at + parseable fallback → hold at fallback, warn + const prevWarn = console.warn + const warnings = [] + console.warn = (...a) => warnings.push(a.join(" ")) + try { + const through = computeProcessedThrough({ uncovered, digest: [], now, fallback }) + assert.equal(through, new Date(fallback).toISOString()) + assert.ok(Date.parse(through) < nowMs) + assert.ok(warnings.some((w) => w.includes("::warning::"))) + } finally { + console.warn = prevWarn + } + + // unresolved + unparseable/missing fallback → throws + assert.throws(() => computeProcessedThrough({ uncovered, digest: [], now }), /fallback|SINCE|refusing/i) + assert.throws( + () => computeProcessedThrough({ uncovered, digest: [], now, fallback: "not-a-date" }), + /fallback|SINCE|refusing/i, + ) + + // resolved merged_at ignores fallback + const throughResolved = computeProcessedThrough({ + uncovered: [{ url: prA.url, reason: "x" }], + digest: [prA], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughResolved, new Date(Date.parse(prA.merged_at) - 1).toISOString()) + + // empty uncovered ignores fallback + const throughEmpty = computeProcessedThrough({ + uncovered: [], + digest: [], + now, + fallback: "2020-01-01T00:00:00.000Z", + }) + assert.equal(throughEmpty, now) + } +} + +// --------------------------------------------------------------------------- +// Case 4 — routing and round trip +// --------------------------------------------------------------------------- +function case4_routing() { + console.log("case 4: routing and round trip") + + const summary = [ + { pr: 1, url: "https://github.com/Kilo-Org/cloud/pull/1", action: "updated pages/a.md", reason: "" }, + { pr: 2, url: "https://github.com/Kilo-Org/cloud/pull/2", action: "skipped", reason: "already documented" }, + { pr: 3, url: "https://github.com/Kilo-Org/cloud/pull/3", action: "pending", reason: "edit batch 1: exit 0" }, + ] + const triage = [ + { + pr: 4, + url: "https://github.com/Kilo-Org/cloud/pull/4", + docs_worthy: false, + pending: false, + reason: "chore only", + }, + { + pr: 5, + url: "https://github.com/Kilo-Org/cloud/pull/5", + docs_worthy: false, + pending: true, + reason: "triage failed to classify this PR", + }, + ] + const worthy = [ + { number: 1, url: summary[0].url }, + { number: 2, url: summary[1].url }, + { number: 3, url: summary[2].url }, + ] + const uncovered = computeUncovered({ worthy, summary, triage }) + const { changesRows, pendingRows, skippedRows } = routeRows({ summary, triage, uncovered }) + + // pending appears in neither Changes nor Considered + const changesText = changesRows.join("\n") + const skippedText = skippedRows.join("\n") + assert.ok(changesText.includes("pull/1"), "success in Changes") + assert.ok(!changesText.includes("pull/3"), "pending must not be in Changes") + assert.ok(!changesText.includes("pull/5"), "triage-pending must not be in Changes") + assert.ok(skippedText.includes("pull/2"), "genuine skipped in Considered") + assert.ok(skippedText.includes("pull/4"), "genuine not-worthy in Considered") + assert.ok(!skippedText.includes("pull/3"), "pending must not be in Considered") + assert.ok(!skippedText.includes("pull/5"), "triage-pending must not be in Considered") + assert.ok(pendingRows.some((r) => r.includes("pull/3"))) + assert.ok(pendingRows.some((r) => r.includes("pull/5"))) + + // round-trip renderBody → extractSectionRows + const through = "2026-07-20T09:59:59.999Z" + const body = renderBody({ + date: "2026-07-27", + since: "2026-07-17T00:00:00.000Z", + through, + changesRows, + pendingRows, + skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + assert.ok(body.includes(``)) + const extChanges = extractSectionRows(body, "changes") + const extPending = extractSectionRows(body, "pending") + const extSkipped = extractSectionRows(body, "skipped") + assert.deepEqual(extChanges, changesRows) + assert.deepEqual(extPending, pendingRows) + assert.deepEqual(extSkipped, skippedRows) + + // clean() prevents marker forgery in agent-generated row strings + { + const forgedRows = routeRows({ + summary: [ + { + pr: 9, + url: "https://github.com/Kilo-Org/cloud/pull/9", + action: "skipped", + reason: "x injection", + }, + ], + triage: [], + uncovered: [], + }) + assert.ok( + !forgedRows.skippedRows[0].includes(""), + "clean() must strip --> from reasons", + ) + const forgedBody = renderBody({ + date: "2026-07-27", + since: "s", + through: "t", + changesRows: [], + pendingRows: [], + skippedRows: forgedRows.skippedRows, + verified: true, + draftReasons: [], + note: "", + }) + // Exactly one real section end marker — the forged sequences were stripped + assert.equal((forgedBody.match(//g) || []).length, 1) + const extracted = extractSectionRows(forgedBody, "skipped") + assert.equal(extracted.length, 1) + assert.ok(extracted[0].includes("injection")) + } + + const legacyRows = [ + "| [Kilo-Org/cloud#1](https://github.com/Kilo-Org/cloud/pull/1) | edit pass failed or timed out for this PR |", + "| [Kilo-Org/cloud#2](https://github.com/Kilo-Org/cloud/pull/2) | triage failed to classify this PR |", + "| [Kilo-Org/cloud#3](https://github.com/Kilo-Org/cloud/pull/3) | not classified by triage |", + "| [Kilo-Org/cloud#4](https://github.com/Kilo-Org/cloud/pull/4) | already covered by existing docs |", + ] + const kept = dropLegacySkipped(legacyRows) + assert.equal(kept.length, 1) + assert.ok(kept[0].includes("pull/4")) + assert.ok(!kept.some((r) => r.includes("edit pass failed"))) + assert.ok(!kept.some((r) => r.includes("triage failed to classify"))) + assert.ok(!kept.some((r) => r.includes("not classified by triage"))) +} + +// --------------------------------------------------------------------------- +// Case 5 — re-collection window +// --------------------------------------------------------------------------- +function case5_recollection() { + console.log("case 5: re-collection closes the loop") + + const collectSrc = fs.readFileSync(COLLECT_SCRIPT, "utf8") + // Query template must use merged:>= + assert.ok( + /merged:>=\$\{since\.toISOString\(\)\}/.test(collectSrc) || /merged:>=/.test(collectSrc), + "collect.mjs must search merged:>=since", + ) + assert.match(collectSrc, /merged:>=/) + + const mergedAt = "2026-07-22T15:30:00.000Z" + const uncovered = [{ url: "https://github.com/Kilo-Org/cloud/pull/11", reason: "pending" }] + const digest = [{ url: uncovered[0].url, merged_at: mergedAt }] + const now = "2026-07-27T12:00:00.000Z" + const since = computeProcessedThrough({ uncovered, digest, now }) + // held-back since is strictly before the uncovered PR's merged_at + assert.ok(Date.parse(since) < Date.parse(mergedAt), `since ${since} must be < merged_at ${mergedAt}`) + // And the query window merged:>=since therefore includes that PR + assert.ok(Date.parse(mergedAt) >= Date.parse(since)) +} + +// --------------------------------------------------------------------------- +// Case 6 — budgets +// --------------------------------------------------------------------------- +function case6_budgets() { + console.log("case 6: budgets") + + // --- edit budget --- + { + // 12 PRs = 3 batches of 5; budget too small for even one batch unit + const prs = Array.from({ length: 12 }, (_, i) => samplePr(100 + i)) + const worthy = prs + const triage = prs.map((p) => ({ + pr: p.number, + url: p.url, + docs_worthy: true, + reason: "needs docs", + target_sections: [], + priority: "medium", + })) + const cwd = setupEditCwd(worthy, triage) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + + // EDIT_BUDGET_MINUTES must be positive (0 falls through to default 50). + // BATCH_TIMEOUT default would be 15m; set both tiny so left < BATCH_TIMEOUT immediately. + const result = runNodeScript(EDIT_SCRIPT, { + cwd, + kiloDir, + env: { + EDIT_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + EDIT_BUDGET_MINUTES: "0.0001", + EDIT_BATCH_TIMEOUT_MINUTES: "15", + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + assert.match(result.output, /deferred \d+ PRs due to wall-clock budget/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred edit batches; got ${callCount}`) + + const summary = JSON.parse(fs.readFileSync(path.join(cwd, ".docs-sync-summary.json"), "utf8")) + assert.ok(summary.every((e) => e.action === "pending")) + const uncovered = computeUncovered({ worthy, summary, triage }) + assert.equal(uncovered.length, 12) + assert.ok(summary.every((e) => e.action !== "skipped")) + } + + // --- triage budget --- + { + // CHUNK_SIZE=25; 30 PRs = 2 chunks; budget too small for a 10m chunk + const digest = Array.from({ length: 30 }, (_, i) => samplePr(200 + i)) + const cwd = setupTriageCwd(digest) + const callLog = path.join(cwd, "kilo-calls.log") + const kiloDir = makeStubKiloDir({ mode: "record", callLog }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "0.0001", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + assert.match(result.output, /deferring \d+ PRs/) + + const calls = fs.existsSync(callLog) ? fs.readFileSync(callLog, "utf8").trim() : "" + const callCount = calls ? calls.split("\n").filter(Boolean).length : 0 + assert.equal(callCount, 0, `kilo must not be invoked for deferred triage chunks; got ${callCount}`) + + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 30) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(triage.every((e) => e.docs_worthy === false)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + assert.equal(uncovered.length, 30) + } +} + +// --------------------------------------------------------------------------- +// Case 7 — applyCap both arms +// --------------------------------------------------------------------------- +function case7_cap() { + console.log("case 7: applyCap") + + const now = new Date("2026-07-27T12:00:00.000Z") + const old = new Date("2026-06-01T00:00:00.000Z") + + const prevLog = console.log + const prevWarn = console.warn + const logs = [] + const warnings = [] + console.log = (...a) => logs.push(a.join(" ")) + console.warn = (...a) => warnings.push(a.join(" ")) + try { + // explicit:false + older than 14 days → clamped AND reported + const a = applyCap(old, now, { explicit: false }) + assert.equal(a.clamped, true) + assert.ok(a.since.getTime() > old.getTime()) + const cap = new Date(now.getTime() - 14 * 24 * 3600 * 1000) + assert.equal(a.since.toISOString(), cap.toISOString()) + assert.ok(warnings.some((w) => w.includes("::warning::") && w.includes("clamped"))) + + // explicit:true + older than 14 days → unchanged, skip reported + logs.length = 0 + warnings.length = 0 + const b = applyCap(old, now, { explicit: true }) + assert.equal(b.clamped, false) + assert.equal(b.since.toISOString(), old.toISOString()) + assert.ok(logs.some((l) => /cap skipped|INPUT_SINCE/i.test(l))) + } finally { + console.log = prevLog + console.warn = prevWarn + } +} + +// --------------------------------------------------------------------------- +// Case 8 — triage.mjs outputs +// --------------------------------------------------------------------------- +function case8_triage() { + console.log("case 8: triage pass outputs") + + // 8a Run A: SINCE_OVERRIDE=true + everything pending → warning present + { + const digest = [samplePr(301), samplePr(302), samplePr(303)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end before idle" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 3) + assert.ok(triage.every((e) => e.pending === true)) + const summary = fs.readFileSync(summaryFile, "utf8") + assert.match(summary, /triage pending/) + for (const d of digest) { + assert.ok(summary.includes(d.url), `summary lists ${d.url}`) + } + assert.match(result.output, /::warning::.*since-override/) + } + + // 8a Run B: SINCE_OVERRIDE unset → warning absent + { + const digest = [samplePr(311), samplePr(312)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "stderr-exit0", stderrText: "stream end" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.ok(triage.every((e) => e.pending === true)) + assert.ok(fs.readFileSync(summaryFile, "utf8").includes("triage pending")) + assert.ok(!/::warning::.*since-override/.test(result.output), "override warning must be absent when unset") + } + + // 8a Run C: SINCE_OVERRIDE=true with MIXED stub (worthy > 0) → warning ABSENT + { + const digest = [samplePr(321), samplePr(322), samplePr(323), samplePr(324)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "mixed-triage" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + SINCE_OVERRIDE: "true", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + const worthy = triage.filter((e) => e.docs_worthy === true).length + const pending = triage.filter((e) => e.pending === true).length + assert.ok(worthy > 0, "mixed stub must produce worthy > 0") + assert.ok(pending > 0, "mixed stub must leave some pending") + assert.ok( + !/::warning::.*since-override/.test(result.output), + "override warning must be ABSENT when worthy > 0 (Upsert will run)", + ) + } + + // 8b — partial classification → missing URLs pending:true + computeUncovered + { + // One chunk of 4 PRs; stub classifies first 3 only + const digest = [samplePr(401), samplePr(402), samplePr(403), samplePr(404)] + const cwd = setupTriageCwd(digest) + const kiloDir = makeStubKiloDir({ mode: "partial-triage" }) + const summaryFile = path.join(cwd, "step-summary.md") + fs.writeFileSync(summaryFile, "") + + const result = runNodeScript(TRIAGE_SCRIPT, { + cwd, + kiloDir, + env: { + TRIAGE_MODEL: "test/model", + DOCS_SYNC_BACKOFF_MS: "0", + TRIAGE_BUDGET_MINUTES: "30", + GITHUB_STEP_SUMMARY: summaryFile, + }, + }) + assert.equal(result.status, 0, result.output) + const triage = JSON.parse(fs.readFileSync(path.join(cwd, "docs-sync-out", "triage.json"), "utf8")) + assert.equal(triage.length, 4) + const missing = triage.filter((e) => e.reason === "not classified by triage") + assert.ok(missing.length >= 1, "backfill must mark unclassified URLs") + assert.ok(missing.every((e) => e.pending === true)) + const uncovered = computeUncovered({ worthy: [], summary: [], triage }) + for (const m of missing) { + assert.ok( + uncovered.some((u) => u.url === m.url), + `${m.url} must appear in computeUncovered`, + ) + } + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- +function main() { + const cases = [ + case1_mergeOrFallback, + case2_defectB, + case3_watermark, + case4_routing, + case5_recollection, + case6_budgets, + case7_cap, + case8_triage, + ] + let failed = 0 + for (const fn of cases) { + try { + fn() + console.log(` ok: ${fn.name}`) + } catch (err) { + failed++ + console.error(` FAIL: ${fn.name}`) + console.error(err) + } finally { + cleanup() + } + } + if (failed > 0) { + console.error(`\nselftest: ${failed} case(s) failed`) + process.exit(1) + } + console.log("\nselftest: all cases passed") +} + +main() diff --git a/.github/docs-sync/triage.mjs b/.github/docs-sync/triage.mjs index a3a5683116..eb075980b5 100644 --- a/.github/docs-sync/triage.mjs +++ b/.github/docs-sync/triage.mjs @@ -6,52 +6,82 @@ * A daily window holds ~30-50 PRs; a replay can hold several hundred. A * single triage call over that volume truncates its JSON output, so the * digest is split into chunks of CHUNK_SIZE and each chunk is triaged with - * its own `kilo run` call. A chunk that fails twice is degraded to - * "unclassified" entries (docs_worthy=false) instead of failing the run — - * the PR body then shows those PRs as skipped, visible to reviewers. + * its own `kilo run` call. A chunk that fails, is only partially classified, + * or is deferred by the wall-clock budget is marked pending:true (still + * docs_worthy:false so filter-worthy excludes it) so the watermark holds + * back and the next run re-collects those PRs. * * Env: TRIAGE_MODEL (provider/model), KILO_API_KEY + KILO_ORG_ID (gateway auth, set by * the workflow; the kilo provider reads them natively). Reads the prompt from triage-prompt.md next to this script. + * Budget: TRIAGE_BUDGET_MINUTES (default 35). Test hook: DOCS_SYNC_BACKOFF_MS. */ -import { execFileSync } from "node:child_process" import fs from "node:fs" import path from "node:path" import { fileURLToPath } from "node:url" import { parseTriageEntries } from "./extract-json.mjs" +import { appendSummary, backoffMsForAttempt, deadline, remainingMs, runKilo, sleepSync } from "./lib.mjs" const CHUNK_SIZE = 25 -const ATTEMPTS = 2 +const ATTEMPTS = 3 const OUT_DIR = "docs-sync-out" +const CHUNK_TIMEOUT_MS = 10 * 60 * 1000 const HERE = path.dirname(fileURLToPath(import.meta.url)) const prompt = fs.readFileSync(path.join(HERE, "triage-prompt.md"), "utf8") const model = process.env.TRIAGE_MODEL if (!model) throw new Error("TRIAGE_MODEL is required") +const TRIAGE_BUDGET_MINUTES = Number(process.env.TRIAGE_BUDGET_MINUTES) || 35 + const digest = JSON.parse(fs.readFileSync(`${OUT_DIR}/digest.json`, "utf8")) -function triageChunk(chunk, index) { +function formatCause(result) { + const bits = [] + if (result.timedOut) bits.push("timed out") + if (result.exitCode !== null && result.exitCode !== undefined) bits.push(`exit ${result.exitCode}`) + if (result.stderrTail) bits.push(result.stderrTail.replaceAll("\n", " ").slice(0, 200)) + return bits.join("; ") || "no diagnostic" +} + +function pendingEntry(d, reason) { + return { + pr: d.number, + url: d.url, + docs_worthy: false, + pending: true, + reason, + target_sections: [], + priority: "medium", + } +} + +function triageChunk(chunk, index, budgetDeadline) { const chunkFile = `${OUT_DIR}/triage-chunk-${index}.json` fs.writeFileSync(chunkFile, JSON.stringify(chunk, null, 2)) + let lastCause = "triage failed to classify this PR" for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { - let raw - try { - // Message positional first: --file is multi-value and would otherwise - // consume a trailing message as a file path ("File not found"). - raw = execFileSync( - "kilo", - ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], - { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 10 * 60 * 1000, stdio: ["ignore", "pipe", "pipe"] }, + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + lastCause = `triage budget exhausted before chunk ${index} attempt ${attempt}` + console.warn( + `chunk ${index}: stopping retries — remaining budget cannot fit another ${CHUNK_TIMEOUT_MS / 60000}m attempt`, ) - } catch (err) { - const stderr = String(err.stderr ?? "").trim().split("\n").slice(-5).join("\n") - console.warn(`chunk ${index} attempt ${attempt}: kilo run failed: ${stderr || err.message}`) - continue + break } - fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) - const entries = parseTriageEntries(raw) + + const result = runKilo({ + args: ["run", prompt, "-m", model, "--dir", process.cwd(), "-f", chunkFile], + timeoutMs: Math.min(CHUNK_TIMEOUT_MS, left), + streamStdout: false, + label: `triage chunk ${index} attempt ${attempt}`, + }) + + const raw = result.stdout + if (raw) fs.writeFileSync(`${OUT_DIR}/triage-raw-${index}.txt`, raw) + + const entries = raw ? parseTriageEntries(raw) : null if (entries) { // An entry for a PR outside this chunk must not win the shared dedupe // against the chunk that actually owns it — drop foreign entries. @@ -62,18 +92,35 @@ function triageChunk(chunk, index) { } if (owned.length > 0) return owned } - console.warn(`chunk ${index} attempt ${attempt}: no valid JSON in output`) + + // Exit 0 is not success: unparseable output is a failure logged WITH + // the captured stderrTail and exit code on every attempt. + const cause = formatCause(result) + lastCause = `triage chunk ${index}: ${cause}` + console.warn( + `chunk ${index} attempt ${attempt}: no valid JSON in output` + + ` (exit ${result.exitCode}${result.timedOut ? ", timed out" : ""})` + + (result.stderrTail ? `\nstderr tail:\n${result.stderrTail}` : "\nstderr tail: (empty)"), + ) + + if (attempt < ATTEMPTS) { + const wait = backoffMsForAttempt(attempt) + const afterWait = remainingMs(budgetDeadline) - wait + if (wait > 0 && afterWait >= CHUNK_TIMEOUT_MS) { + console.warn(`chunk ${index}: backing off ${wait / 1000}s before attempt ${attempt + 1}`) + sleepSync(wait) + } else if (wait > 0) { + console.warn( + `chunk ${index}: skipping backoff — remaining budget cannot fit attempt ${attempt + 1} after wait`, + ) + } + } } - console.warn(`::warning::chunk ${index} failed triage after ${ATTEMPTS} attempts; marking ${chunk.length} PRs unclassified`) - return chunk.map((d) => ({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "triage failed to classify this PR", - target_sections: [], - priority: "medium", - })) + console.warn( + `::warning::chunk ${index} failed triage after up to ${ATTEMPTS} attempts; marking ${chunk.length} PRs pending`, + ) + return chunk.map((d) => pendingEntry(d, lastCause.includes("triage failed") ? lastCause : `triage failed to classify this PR (${lastCause})`)) } const chunks = [] @@ -82,30 +129,61 @@ for (let i = 0; i < digest.length; i += CHUNK_SIZE) { } console.log(`triaging ${digest.length} PRs in ${chunks.length} chunks of up to ${CHUNK_SIZE}`) +const budgetDeadline = deadline(TRIAGE_BUDGET_MINUTES) const merged = [] const seen = new Set() + for (let i = 0; i < chunks.length; i++) { - for (const e of triageChunk(chunks[i], i)) { + const left = remainingMs(budgetDeadline) + if (left < CHUNK_TIMEOUT_MS) { + const deferredPrs = chunks.slice(i).reduce((n, c) => n + c.length, 0) + console.warn( + `stopping triage before chunk ${i}: remaining budget (${Math.ceil(left / 1000)}s) cannot fit a ${CHUNK_TIMEOUT_MS / 60000}m chunk; deferring ${deferredPrs} PRs`, + ) + const cause = `triage budget exhausted before chunk ${i} (${Math.ceil(left / 1000)}s left)` + for (let j = i; j < chunks.length; j++) { + for (const d of chunks[j]) { + if (seen.has(d.url)) continue + seen.add(d.url) + merged.push(pendingEntry(d, cause)) + } + } + break + } + + for (const e of triageChunk(chunks[i], i, budgetDeadline)) { if (seen.has(e.url)) continue seen.add(e.url) merged.push(e) } } -// Coverage: every digest PR gets a triage entry so the PR body's skipped -// table is complete. Unclassified defaults to not-docs-worthy (conservative). +// Coverage: every digest PR gets a triage entry. Partial-chunk backfill and +// any other missing URL are pending:true — not a genuine "not worthy" verdict. for (const d of digest) { if (seen.has(d.url)) continue - merged.push({ - pr: d.number, - url: d.url, - docs_worthy: false, - reason: "not classified by triage", - target_sections: [], - priority: "medium", - }) + merged.push(pendingEntry(d, "not classified by triage")) } fs.writeFileSync(`${OUT_DIR}/triage.json`, JSON.stringify(merged, null, 2)) const worthy = merged.filter((e) => e.docs_worthy).length -console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy`) +const pending = merged.filter((e) => e.pending === true) +console.log(`triage complete: ${merged.length} entries, ${worthy} docs-worthy, ${pending.length} pending`) + +// Upsert is gated off when worthy == 0, so triage emits its own Step Summary +// listing every PR it marked pending:true and why. +if (pending.length > 0) { + const lines = pending.map((e) => `- [${e.url}] ${e.reason}`) + appendSummary( + `### docs-sync: triage pending (will retry)\n\n${pending.length} PR(s) were not classified and will be re-collected on the next run:\n\n${lines.join("\n")}`, + ) +} + +// Replay warning (S2j): warn IFF since-override AND something pending AND +// docs-worthy count is 0 (Upsert is gated off, so noDiffReport never runs). +const sinceOverride = process.env.SINCE_OVERRIDE === "true" +if (sinceOverride && pending.length > 0 && worthy === 0) { + console.warn( + "::warning::docs-sync since-override replay left uncovered PRs and wrote no PR body (worthy=0); re-run the override — the watermark was not held back in the body", + ) +} diff --git a/.github/docs-sync/upsert-pr.mjs b/.github/docs-sync/upsert-pr.mjs index e39551f307..1ef25b7596 100644 --- a/.github/docs-sync/upsert-pr.mjs +++ b/.github/docs-sync/upsert-pr.mjs @@ -8,6 +8,14 @@ * diff exceeds the file cap or verification failed. The PR body carries * marker-delimited sections so later runs can append rows, plus a * machine-readable processed-through watermark. + * + * Watermark invariant: processed-through never moves past a PR that has no + * terminal outcome. Terminal := action !== "pending" (a deliberate agent + * "skipped" IS terminal). Uncovered PRs hold the marker at earliest + * merged_at − 1 ms so collect's merged:>=since re-collects them next run. + * Three review rounds found four independent defects in a queue-based + * alternative (unreachable gate, empty-PR creation, cap-overflow loss, + * draft-state corruption); a held-back watermark has none of those modes. */ import { execFileSync } from "node:child_process" @@ -17,6 +25,7 @@ import { pathToFileURL } from "node:url" const BRANCH = process.env.BRANCH || "docs/auto-sync" const FILE_CAP = 15 const ROW_CAP = 150 +const PENDING_DISPLAY_CAP = 60 const SUMMARY_FILE = ".docs-sync-summary.json" const DOCS_PATH = "packages/kilo-docs" @@ -42,6 +51,11 @@ function skippedRow(e) { return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` } +function pendingRow(e) { + const reason = clean(e.reason ?? e.cause ?? "").replaceAll("|", "\\|").replaceAll("\n", " ") + return `| [${shortRef(e.url)}](${clean(e.url)}) | ${reason} |` +} + export function extractSectionRows(body, name) { const m = String(body ?? "").match( new RegExp(`([\\s\\S]*?)`), @@ -50,7 +64,14 @@ export function extractSectionRows(body, name) { return m[1] .split("\n") .map((l) => l.trim()) - .filter((l) => l.startsWith("|") && !l.startsWith("| ---") && !/^\|\s*Docs change/.test(l) && !/^\|\s*PR\s*\|/.test(l)) + .filter( + (l) => + l.startsWith("|") && + !l.startsWith("| ---") && + !/^\|\s*Docs change/.test(l) && + !/^\|\s*PR\s*\|/.test(l) && + !/^\|\s*Why\s*\|/.test(l), + ) } function section(name, header, rows) { @@ -58,7 +79,12 @@ function section(name, header, rows) { return `\n${body}\n` } -export function renderBody({ date, since, through, changesRows, skippedRows, verified, draftReasons, note }) { +export function renderBody({ date, since, through, changesRows, pendingRows, skippedRows, verified, draftReasons, note }) { + const pendingDisplay = + pendingRows.length > PENDING_DISPLAY_CAP + ? [...pendingRows.slice(0, PENDING_DISPLAY_CAP), `| +${pendingRows.length - PENDING_DISPLAY_CAP} more | |`] + : pendingRows + return `## Automated docs sync — ${date} This PR keeps kilo.ai/docs in sync with features merged to [Kilo-Org/cloud](https://github.com/Kilo-Org/cloud) and [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode). Every change below links to the merged PR it documents. @@ -70,6 +96,10 @@ ${note ? `- ${note}\n` : ""}${draftReasons.length > 0 ? `- Draft because: ${draf ${section("changes", "| Docs change | Source |", changesRows)} +### Pending — will retry + +${section("pending", "| PR | Why |", pendingDisplay)} + ### Considered, no docs change needed ${section("skipped", "| PR | Reason |", skippedRows)} @@ -100,32 +130,222 @@ function readJson(path, fallback) { } } +/** + * Uncovered = (worthy URLs with no summary row) ∪ (summary action "pending") + * ∪ (triage entries with pending: true). A worthy PR is covered iff it has a + * summary row whose action !== "pending" and carries no triage pending flag. + */ +export function computeUncovered({ worthy, summary, triage }) { + const worthyList = Array.isArray(worthy) ? worthy : [] + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + + const summaryByUrl = new Map() + for (const e of summaryList) { + if (e?.url) summaryByUrl.set(e.url, e) + } + + const triagePendingByUrl = new Map() + for (const e of triageList) { + if (e?.url && e.pending === true) triagePendingByUrl.set(e.url, e) + } + + /** @type {Map} */ + const out = new Map() + + for (const w of worthyList) { + const url = w?.url + if (!url) continue + const row = summaryByUrl.get(url) + if (!row) { + out.set(url, { + url, + pr: w.number ?? w.pr, + reason: "no edit summary row (edit pass did not cover this PR)", + }) + continue + } + if (row.action === "pending") { + out.set(url, { + url, + pr: row.pr ?? w.number ?? w.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + // Summary pending rows for URLs not in worthy (defensive). + for (const row of summaryList) { + if (row?.action === "pending" && row.url && !out.has(row.url)) { + out.set(row.url, { + url: row.url, + pr: row.pr, + reason: row.reason || "edit pass pending", + }) + } + } + + for (const [url, e] of triagePendingByUrl) { + if (out.has(url)) continue + out.set(url, { + url, + pr: e.pr, + reason: e.reason || "triage pending", + }) + } + + return [...out.values()] +} + +/** + * processed-through = now when uncovered is empty; otherwise earliest + * merged_at among uncovered PRs minus 1 ms (from digest-full.json). + * When uncovered is non-empty but no merged_at resolves, hold at + * `fallback` (the run's window start / SINCE): every uncovered PR was + * collected via merged:>=since, so holding there re-collects all of them. + * Never advance past unresolved uncovered PRs (Defect-B permanent-loss). + */ +export function computeProcessedThrough({ uncovered, digest, now, fallback }) { + const nowIso = typeof now === "string" ? now : new Date(now).toISOString() + if (!uncovered || uncovered.length === 0) return nowIso + + const digestList = Array.isArray(digest) ? digest : [] + const byUrl = new Map(digestList.filter((d) => d?.url).map((d) => [d.url, d])) + + let earliest = null + for (const u of uncovered) { + const d = byUrl.get(u.url) + const mergedAt = d?.merged_at + if (!mergedAt) continue + const t = Date.parse(mergedAt) + if (!Number.isFinite(t)) continue + if (earliest === null || t < earliest) earliest = t + } + + if (earliest === null) { + // digest-full missing/corrupt while uncovered is non-empty: hold at + // window start so collect's merged:>=since re-collects every PR. + // Never use now−1ms — that strands uncovered PRs permanently. + const fallbackMs = fallback == null ? NaN : Date.parse(fallback) + if (!Number.isFinite(fallbackMs)) { + throw new Error( + `docs-sync: cannot resolve merged_at for ${uncovered.length} uncovered PR(s) and fallback/SINCE is missing or unparseable; refusing to advance processed-through`, + ) + } + const fallbackIso = new Date(fallbackMs).toISOString() + console.warn( + `::warning::docs-sync: merge times for ${uncovered.length} uncovered PR(s) could not be resolved; holding watermark at window start ${fallbackIso}`, + ) + return fallbackIso + } + + return new Date(earliest - 1).toISOString() +} + +/** + * Route summary + triage into the three body sections. + * changesRows = action neither skipped nor pending + * pendingRows = uncovered from computeUncovered + * skippedRows = action === "skipped" ∪ triage docs_worthy false && !pending + */ +export function routeRows({ summary, triage, uncovered }) { + const summaryList = Array.isArray(summary) ? summary : [] + const triageList = Array.isArray(triage) ? triage : [] + const uncoveredList = Array.isArray(uncovered) ? uncovered : [] + + const changesEntries = summaryList.filter((e) => e.action !== "skipped" && e.action !== "pending") + const skippedEntries = [ + ...triageList.filter((e) => e.docs_worthy === false && e.pending !== true), + ...summaryList.filter((e) => e.action === "skipped"), + ] + + return { + changesRows: changesEntries.map(changeRow), + pendingRows: uncoveredList.map(pendingRow), + skippedRows: skippedEntries.map(skippedRow), + } +} + +/** + * Drop pre-existing Considered rows whose reason contains any of the three + * legacy failure literals (substring match — live rows carry longer strings). + * Genuine no-doc-needed rows are untouched. + */ +export function dropLegacySkipped(rows) { + const list = Array.isArray(rows) ? rows : [] + const needles = ["edit pass failed or timed out", "triage failed to classify", "not classified by triage"] + return list.filter((row) => { + const s = String(row ?? "") + return !needles.some((n) => s.includes(n)) + }) +} + +/** + * No-diff early-return report. Returns summary markdown and an optional + * replay warning. Warns IFF sinceOverride && uncovered non-empty (no commit + * happened — that is the caller's situation). + */ +export function noDiffReport({ uncovered, sinceOverride }) { + const list = Array.isArray(uncovered) ? uncovered : [] + const lines = + list.length === 0 + ? ["The agent found nothing worth documenting in this window."] + : [ + `No packages/kilo-docs diff was produced, but ${list.length} PR(s) remain uncovered and will be re-collected on the next scheduled run:`, + "", + ...list.map((u) => `- [${u.url}] ${u.reason || "uncovered"}`), + ] + + const summary = `### docs-sync: no docs changes\n\n${lines.join("\n")}` + + let warning = null + if (sinceOverride && list.length > 0) { + warning = + "docs-sync since-override replay left uncovered PRs and wrote no PR body (no docs commit); re-run the override — the watermark was not held back in the body" + } + + return { summary, warning } +} + async function main() { const { api, appendOutput, appendSummary, repo } = await import("./lib.mjs") - const through = process.env.PROCESSED_THROUGH ?? new Date().toISOString() + const now = process.env.PROCESSED_THROUGH ?? new Date().toISOString() const since = process.env.SINCE ?? "unknown" + const sinceOverride = process.env.SINCE_OVERRIDE === "true" const mode = ["update", "conflict"].includes(process.env.PREP_MODE) ? process.env.PREP_MODE : "fresh" const existingPr = process.env.PR_NUMBER || "" const verified = process.env.VERIFIED === "true" - const date = through.slice(0, 10) + const date = now.slice(0, 10) // The agent's run summary is consumed here and never committed. const agentSummary = readJson(SUMMARY_FILE, []) fs.rmSync(SUMMARY_FILE, { force: true }) const triage = readJson("docs-sync-out/triage.json", []) + const worthy = readJson("docs-sync-out/worthy.json", []) + const digest = readJson("docs-sync-out/digest-full.json", []) + + // Order matters: compute uncovered BEFORE the no-diff early return so + // noDiffReport can name every held-back PR. + const uncovered = computeUncovered({ worthy, summary: agentSummary, triage }) if (git(["status", "--porcelain", "--", DOCS_PATH]) === "") { console.log("no packages/kilo-docs changes produced; nothing to commit") - appendSummary("### docs-sync: no docs changes\n\nThe agent found nothing worth documenting in this window.") + const { summary, warning } = noDiffReport({ uncovered, sinceOverride }) + appendSummary(summary) + if (warning) console.warn(`::warning::${warning}`) return } - git(["config", "user.name", "github-actions[bot]"]) - git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"]) + // Git identity is configured once in docs-sync.yml (Configure git identity) + // before any commit-creating step, including prepare-branch's merge. git(["add", DOCS_PATH]) git(["commit", "-m", `docs: sync with merged PRs (${date})`]) + // Watermark: now when fully covered; else earliest uncovered merged_at − 1ms. + // Pass SINCE as fallback so missing digest-full cannot strand uncovered PRs. + const through = computeProcessedThrough({ uncovered, digest, now, fallback: since }) + // The draft cap bounds the cumulative PR diff, not just this run's commit. const changedFiles = git(["diff", "--name-only", "origin/main...HEAD", "--", DOCS_PATH]) .split("\n") @@ -151,25 +371,33 @@ async function main() { git(mode === "update" ? ["push", "origin", `HEAD:${BRANCH}`] : ["push", "--force-with-lease", "origin", `HEAD:${BRANCH}`]) - const changesNew = agentSummary.filter((e) => e.action !== "skipped").map(changeRow) - const skippedNew = [ - ...triage.filter((e) => e.docs_worthy === false), - ...agentSummary.filter((e) => e.action === "skipped"), - ].map(skippedRow) + const { changesRows: changesNew, pendingRows: pendingNew, skippedRows: skippedNew } = routeRows({ + summary: agentSummary, + triage, + uncovered, + }) let oldChanges = [] let oldSkipped = [] + let oldPending = [] if (mode === "update" && existingPr) { const pr = await api(`/repos/${repo()}/pulls/${existingPr}`) oldChanges = extractSectionRows(pr.body, "changes") - oldSkipped = extractSectionRows(pr.body, "skipped") + oldSkipped = dropLegacySkipped(extractSectionRows(pr.body, "skipped")) + oldPending = extractSectionRows(pr.body, "pending") } + // Pending is replaced each run (informational only); do not merge legacy + // pending rows — uncovered is recomputed fresh. oldPending is read only so + // extractSectionRows stays exercised; discarded deliberately. + void oldPending + const body = renderBody({ date, since, through, changesRows: mergeRows(oldChanges, changesNew), + pendingRows: pendingNew, skippedRows: mergeRows(oldSkipped, skippedNew), verified, draftReasons, @@ -228,8 +456,10 @@ async function main() { } appendOutput("pr_url", prUrl) - appendSummary(`### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n`) - console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length})`) + appendSummary( + `### docs-sync PR\n\n- ${prUrl}\n- changed files: ${changedFiles.length}\n- draft: ${draft}\n- uncovered: ${uncovered.length}\n- processed-through: ${through}\n`, + ) + console.log(`PR ${prNumber}: ${prUrl} (draft=${draft}, files=${changedFiles.length}, uncovered=${uncovered.length}, through=${through})`) } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href diff --git a/.github/docs-sync/watermark.mjs b/.github/docs-sync/watermark.mjs index cc4c969016..32986fef67 100644 --- a/.github/docs-sync/watermark.mjs +++ b/.github/docs-sync/watermark.mjs @@ -7,9 +7,10 @@ * * Priority: workflow_dispatch input `since` > latest open bot PR marker > * last merged bot PR marker > 72h ago. Hard cap: never look back more than - * 14 days. + * 14 days — unless the human explicitly requested a window via INPUT_SINCE. */ +import { pathToFileURL } from "node:url" import { appendOutput, appendSummary, repo, searchIssues } from "./lib.mjs" const FALLBACK_HOURS = 72 @@ -42,34 +43,65 @@ async function findWatermark() { return null } -const now = new Date() -let since - -const input = (process.env.INPUT_SINCE ?? "").trim() -if (input) { - since = new Date(input) - if (Number.isNaN(since.getTime())) { - throw new Error(`Invalid INPUT_SINCE: ${input}`) +/** + * Apply the 14-day lookback cap. When `explicit` is true (dispatch override), + * the cap is skipped so a human-requested recovery window is not silently + * shortened. Returns `{ since, clamped }`. + */ +export function applyCap(since, now, { explicit = false } = {}) { + if (explicit) { + console.log(`14-day cap skipped: INPUT_SINCE was set explicitly (${since.toISOString()})`) + return { since, clamped: false } } - console.log(`watermark from dispatch input: ${since.toISOString()}`) -} else { - since = - (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) + if (since < cap) { + const from = since.toISOString() + const to = cap.toISOString() + console.warn( + `::warning::docs-sync watermark clamped from ${from} to ${to} (${CAP_DAYS}-day cap). ` + + `Anything still uncovered before ${to} is abandoned and needs a human.`, + ) + return { since: cap, clamped: true } + } + return { since, clamped: false } } -// A forged, edited, or malformed marker in the future would silently match -// nothing in the merged:>= search; clamp it loudly. -if (since > now) { - console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) - since = now +async function main() { + const now = new Date() + let since + let explicit = false + + const input = (process.env.INPUT_SINCE ?? "").trim() + if (input) { + since = new Date(input) + if (Number.isNaN(since.getTime())) { + throw new Error(`Invalid INPUT_SINCE: ${input}`) + } + explicit = true + console.log(`watermark from dispatch input: ${since.toISOString()}`) + } else { + since = (await findWatermark()) ?? new Date(now.getTime() - FALLBACK_HOURS * 3600 * 1000) + } + + // A forged, edited, or malformed marker in the future would silently match + // nothing in the merged:>= search; clamp it loudly. + if (since > now) { + console.warn(`watermark ${since.toISOString()} is in the future, clamping to now`) + since = now + } + + ;({ since } = applyCap(since, now, { explicit })) + + appendOutput("since", since.toISOString()) + appendOutput("now", now.toISOString()) + appendOutput("since_override", explicit ? "true" : "false") + appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) } -const cap = new Date(now.getTime() - CAP_DAYS * 24 * 3600 * 1000) -if (since < cap) { - console.log(`watermark ${since.toISOString()} older than ${CAP_DAYS}d cap, clamping`) - since = cap +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (isMain) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) } - -appendOutput("since", since.toISOString()) -appendOutput("now", now.toISOString()) -appendSummary(`### docs-sync watermark\n\n- since: \`${since.toISOString()}\`\n- now: \`${now.toISOString()}\`\n`) diff --git a/.github/workflows/docs-sync.yml b/.github/workflows/docs-sync.yml index ffc803c17c..5a32e87698 100644 --- a/.github/workflows/docs-sync.yml +++ b/.github/workflows/docs-sync.yml @@ -5,9 +5,13 @@ name: docs-sync # triages them for docs relevance, runs Kilo CLI headless to update # packages/kilo-docs, and maintains one rolling PR for human review. # -# Security posture: scheduled/manual only, checks out main, never executes -# code from PR branches. State is derived from the bot's own PRs (watermark -# marker in the PR body), so missed or failed runs self-heal on the next run. +# Security posture: scheduled/manual runs check out the dispatched ref and may +# push/comment with write permissions and org secrets. PR runs (paths-limited to +# this workflow and .github/docs-sync/**) execute branch code only in a +# read-only, secretless `selftest` job that never pushes, comments, or calls an +# LLM. `pull_request` (not `pull_request_target`) keeps fork tokens read-only. +# State is derived from the bot's own PRs (watermark marker in the PR body), so +# missed or failed runs self-heal on the next run. on: schedule: @@ -22,6 +26,10 @@ on: description: "Collect + triage only, no edits, no PR" type: boolean default: false + pull_request: + paths: + - ".github/docs-sync/**" + - ".github/workflows/docs-sync.yml" permissions: contents: write # push the rolling branch, create the auto-docs label @@ -29,7 +37,7 @@ permissions: issues: write # comment on the rolling PR concurrency: - group: docs-sync + group: ${{ github.event_name == 'pull_request' && format('docs-sync-pr-{0}', github.event.pull_request.number) || 'docs-sync' }} cancel-in-progress: false env: @@ -37,9 +45,28 @@ env: EDIT_MODEL: ${{ vars.DOCS_SYNC_EDIT_MODEL || 'kilo/moonshotai/kimi-k3' }} jobs: - sync: + selftest: if: github.repository == 'Kilo-Org/kilocode' runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + + sync: + if: github.repository == 'Kilo-Org/kilocode' && github.event_name != 'pull_request' + runs-on: blacksmith-4vcpu-ubuntu-2404 + # Budget: 3 setup/collect + 35 triage + 50 edit + 2 verify + 10 fix + 2 upsert = 102 min, 18-minute reserve. timeout-minutes: 120 env: # Both are required: without KILO_ORG_ID the gateway bills the key @@ -52,12 +79,20 @@ jobs: with: fetch-depth: 0 # prepare-branch merges main into the rolling branch + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + - name: Setup Node uses: actions/setup-node@v6 with: node-version: "24" package-manager-cache: false + - name: Run docs-sync selftest + run: node .github/docs-sync/selftest.mjs + - name: Install Kilo CLI run: | npm install -g @kilocode/cli @@ -79,6 +114,8 @@ jobs: - name: Triage merged PRs (LLM, chunked) id: triage if: steps.collect.outputs.count != '0' + env: + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} run: node .github/docs-sync/triage.mjs - name: Filter docs-worthy PRs @@ -104,8 +141,18 @@ jobs: GH_TOKEN: ${{ github.token }} run: node .github/docs-sync/prepare-branch.mjs + # After prepare-branch checks out the rolling branch and merges main, the + # worktree holds main's scripts. Restore the dispatched ref's copies so a + # branch-dispatch AC9 run actually exercises the fixed code. git restore + # (not checkout) leaves them unstaged so upsert-pr's bare commit won't + # include them in the docs PR. + - name: Restore docs-sync scripts from the dispatched ref + if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + run: git restore --source=${{ github.sha }} -- .github/docs-sync + - name: Update docs (Kilo CLI, batched) if: (steps.worthy.outputs.count || '0') != '0' && inputs.dry_run != true + continue-on-error: true run: node .github/docs-sync/edit.mjs - name: Verify docs build and tests @@ -122,6 +169,7 @@ jobs: id: fix if: steps.verify.outcome == 'failure' continue-on-error: true + timeout-minutes: 10 env: NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.POSTHOG_API_KEY }} run: | @@ -150,6 +198,7 @@ jobs: GH_TOKEN: ${{ github.token }} PROCESSED_THROUGH: ${{ steps.wm.outputs.now }} SINCE: ${{ steps.wm.outputs.since }} + SINCE_OVERRIDE: ${{ steps.wm.outputs.since_override }} BRANCH: ${{ steps.prep.outputs.branch }} PREP_MODE: ${{ steps.prep.outputs.mode }} PR_NUMBER: ${{ steps.prep.outputs.pr_number }} diff --git a/.github/workflows/publish-jetbrains-bundled.yml b/.github/workflows/publish-jetbrains-bundled.yml index 56b331680e..5908b25e73 100644 --- a/.github/workflows/publish-jetbrains-bundled.yml +++ b/.github/workflows/publish-jetbrains-bundled.yml @@ -142,23 +142,43 @@ jobs: JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Write signing secrets to temp files + run: | + dir="$RUNNER_TEMP/jetbrains-signing" + mkdir -m 700 -p "$dir" + chain="$dir/certificate-chain.pem" + key="$dir/private-key.pem" + umask 077 + printf '%s' "$JETBRAINS_CERTIFICATE_CHAIN" > "$chain" + printf '%s' "$JETBRAINS_PRIVATE_KEY" > "$key" + chmod 600 "$chain" "$key" + echo "JETBRAINS_CERTIFICATE_CHAIN_FILE=$chain" >> "$GITHUB_ENV" + echo "JETBRAINS_PRIVATE_KEY_FILE=$key" >> "$GITHUB_ENV" + env: + JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} + JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} + - name: Build signed bundled plugin working-directory: packages/kilo-jetbrains run: | - ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin \ - -Pproduction=true \ - -Pkilo.version="$VERSION" \ - -Pkilo.channel="$CHANNEL" \ + args=( + -Pproduction=true + -Pkilo.version="$VERSION" + -Pkilo.channel="$CHANNEL" -Pkilo.cli.bundled=true + ) + ./gradlew clean buildPlugin signPlugin verifyPluginSignature verifyPlugin "${args[@]}" env: GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }} VERSION: ${{ needs.validate.outputs.version }} CHANNEL: ${{ needs.validate.outputs.channel }} - JETBRAINS_CERTIFICATE_CHAIN: ${{ secrets.JETBRAINS_CERTIFICATE_CHAIN }} - JETBRAINS_PRIVATE_KEY: ${{ secrets.JETBRAINS_PRIVATE_KEY }} JETBRAINS_PRIVATE_KEY_PASSWORD: ${{ secrets.JETBRAINS_PRIVATE_KEY_PASSWORD }} + - name: Remove signing secret temp files + if: always() + run: rm -rf "$RUNNER_TEMP/jetbrains-signing" + - name: Resolve bundled archive id: archive run: | diff --git a/.opencode-version b/.opencode-version index f0257c2afb..6d4057fbe4 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.17.5 +v1.17.9 diff --git a/CONTEXT.md b/CONTEXT.md index bb0e319018..c937cbaabe 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -52,6 +52,9 @@ _Avoid_: Request body, wire options **Generation Controls**: Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. +**PTY Environment**: +The host-supplied environment overlay applied by the server when creating a PTY, observed for the request Location and resolved PTY working directory. + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -99,6 +102,8 @@ Provider-neutral sampling and output controls, partitioned from provider semanti - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. - **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. - **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. +- The **PTY Environment** is a server concern rather than a Core PTY concern. PTY creation merges caller values, then the host overlay, then Core-forced terminal invariants such as `TERM` and `KILO_TERMINAL`. +- A **PTY Environment** adapter observes plugins in the request Location while passing the resolved PTY working directory to the hook; standalone servers use an empty adapter. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. diff --git a/bun.lock b/bun.lock index bd25540b5f..fd599605c4 100644 --- a/bun.lock +++ b/bun.lock @@ -798,6 +798,7 @@ "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@pierre/diffs": "catalog:", + "@shikijs/stream": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", "@solid-primitives/event-listener": "2.4.5", @@ -844,22 +845,22 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", @@ -898,13 +899,15 @@ "@opentui/core": "0.3.4", "@opentui/keymap": "0.3.4", "@opentui/solid": "0.3.4", - "@pierre/diffs": "1.1.22", + "@pierre/diffs": "1.2.10", "@playwright/test": "1.59.1", + "@shikijs/stream": "4.2.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@tailwindcss/vite": "4.1.11", + "@tanstack/solid-virtual": "3.13.28", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", "@types/bun": "1.3.14", @@ -931,7 +934,7 @@ "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-js": "1.9.12", "solid-list": "0.3.0", "sst": "4.13.1", @@ -1956,9 +1959,11 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA=="], - "@pierre/diffs": ["@pierre/diffs@1.1.22", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-1Iv7kdl6OABFCd1n2HQbGUiRHouXPaHoIjcb7Lwg8zeJKY5ph+cESFcGEyIiwW0NCbKGtYS2bTnXmI+Eze5dwg=="], + "@pierre/diffs": ["@pierre/diffs@1.2.10", "", { "dependencies": { "@pierre/theme": "1.0.3", "@pierre/theming": "0.0.1", "@shikijs/transformers": "^3.0.0 || ^4.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0 || ^4.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-rPeAmDWarxFVTQpaf4y6wTxjZxU44xKJKoJti2zU21P06DVd9nRHZX+xSIObLB307Qjpaesyb1x/j0z94t7vLw=="], - "@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="], + "@pierre/theme": ["@pierre/theme@1.0.3", "", {}, "sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA=="], + + "@pierre/theming": ["@pierre/theming@0.0.1", "", { "peerDependencies": { "@pierre/theme": "^1.0.0", "@shikijs/themes": "^3.0.0 || ^4.0.0", "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0", "shiki": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@pierre/theme", "@shikijs/themes", "react", "react-dom", "shiki"] }, "sha512-1thlEtJbqdyLzc1ZS2KQa1q7FzDGHT4dTEdKHoyQjOMeWWOmbVG5/ndEfOKfAb5Fzkz8cNJrOjFLiZoDH/A03A=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -2076,15 +2081,19 @@ "@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], - "@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], + "@shikijs/core": ["@shikijs/core@4.2.0", "", { "dependencies": { "@shikijs/primitive": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-Hc87Ab1Ld/vEbZRCbwx344I5v+4RU8CVToUTRkqXL1+TjbuOp9U5Xa0M23V4GEWHxVn+yO5otb+HkQVm3ptWQQ=="], - "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-OFx8fHAZuk7I42Z9YAdZ95To6jDePQ9Rnfbw9uSRTSbBhYBp1kEOKv/3jOimcj3VRUKusDYM6DswLauwfhboLg=="], + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-fjETeq1k5ffyXqRgS6+3hpvqseLalp1kjNfRbXpUgWR8FpZ1CmQfiNHovc5lncYjt/Vg5JK/WJEmLahjwMa0og=="], - "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-Yx3gy7xLzM0ZOjqoxciHjA7dAt5tyzJE3L4uQoM83agahy+PlW244XJSrmJRSBvGYELDhYXPacD4R/cauV5bzQ=="], + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-hTorK1dffPkpbMUk6Z+828PgRo7d07HbnizoP0hNPFjhxMHctj0Px/qoHeGMYafc6ju+u9iMldN4JbVzNQM++g=="], - "@shikijs/langs": ["@shikijs/langs@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-le+bssCxcSHrygCWuOrYJHvjus6zhQ2K7q/0mgjiffRbkhM4o1EWu2m+29l0yEsHDbWaWPNnDUTRVVBvBBeKaA=="], + "@shikijs/langs": ["@shikijs/langs@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-bwrVRlJ0wUhZxAbVdvBbv2TTC9yLsh4C/IO5Ofz0T8MQntgDvyVnkbjw9vi50r1kx7RCIJdnJnjZAwmAsXFLZQ=="], - "@shikijs/themes": ["@shikijs/themes@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0" } }, "sha512-U1NSU7Sl26Q7ErRvJUouArxfM2euWqq1xaSrbqMu2iqa+tSp0D1Yah8216sDYbdDHw4C8b75UpE65eWorm2erQ=="], + "@shikijs/primitive": ["@shikijs/primitive@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-NOq+DtUkVBJtZMVXL5A0vI0Xk8nvDYaXetFHSJFlOqjDZIVhIPRYFdGkSoElDqNuegikcc3A76SNUa8dTqtAYA=="], + + "@shikijs/stream": ["@shikijs/stream@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0" }, "peerDependencies": { "react": "^19.0.0", "solid-js": "^1.9.0", "vue": "^3.2.0" }, "optionalPeers": ["react", "solid-js", "vue"] }, "sha512-OaMUUStdIZ+l1GJad9uVACR3Xvgwo4y+RmEuDMU62cgFMMg1IBCaIFmvzAR2HiCpGtwoc/qPfpNnP+ivgrPXZg=="], + + "@shikijs/themes": ["@shikijs/themes@4.2.0", "", { "dependencies": { "@shikijs/types": "4.2.0" } }, "sha512-RX8IHYeLv8Cu2W6ruc3RxUqWn0IYCqSrMBzi/uRGAmfyDNOnNO5BF/Px7o97n4XTpmFTo5GbRaazuOWj+2ak2w=="], "@shikijs/transformers": ["@shikijs/transformers@3.9.2", "", { "dependencies": { "@shikijs/core": "3.9.2", "@shikijs/types": "3.9.2" } }, "sha512-MW5hT4TyUp6bNAgTExRYLk1NNasVQMTCw1kgbxHcEC0O5cbepPWaB+1k+JzW9r3SP2/R8kiens8/3E6hGKfgsA=="], @@ -4306,7 +4315,7 @@ "shell-quote": ["shell-quote@1.8.4", "", {}, "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ=="], - "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], + "shiki": ["shiki@4.2.0", "", { "dependencies": { "@shikijs/core": "4.2.0", "@shikijs/engine-javascript": "4.2.0", "@shikijs/engine-oniguruma": "4.2.0", "@shikijs/langs": "4.2.0", "@shikijs/themes": "4.2.0", "@shikijs/types": "4.2.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-hjNax6o/ylDy9lefQEaSDtzaT3iVNtZ3WmpQnbuQNoG4xvnSKf2kSKbihZVO4JRG1TTMejs7CmNRYlWgAL66pQ=="], "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], @@ -5160,13 +5169,19 @@ "@qdrant/js-client-rest/undici": ["undici@6.26.0", "", {}, "sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A=="], - "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/core/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/langs/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], - "@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "@shikijs/langs/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/primitive/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/themes/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], + + "@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.9.2", "", { "dependencies": { "@shikijs/types": "3.9.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3q/mzmw09B2B6PgFNeiaN8pkNOixWS726IHmJEpjDAcneDPMQmUg2cweT9cWXY4XcyQS3i6mOOUgQz9RRUP6HA=="], "@smithy/config-resolver/@smithy/core": ["@smithy/core@3.24.6", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug=="], @@ -5594,9 +5609,7 @@ "sharp/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "shiki/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], - - "shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], + "shiki/@shikijs/types": ["@shikijs/types@4.2.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-VT/MKtlpOhEPZloSH3Pb9WCZEBDoQVMa9jedp5UAwmJOar1DVc9DRODAxmYPW9M93IK4ryuqRejFfmlvlVDemw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], diff --git a/bunfig.toml b/bunfig.toml index f36a00828c..61bb6e0fdb 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/nix/hashes.json b/nix/hashes.json index c10bd672c3..8f0a833b46 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UHxMHmx17Jex0yXgbpXCvIORubs9cFMsIXGacvs9+gA=", - "aarch64-linux": "sha256-tTnW84VaNEbfo46H24ETKZgFumZMwu6pgNDlY8KYkqo=", - "aarch64-darwin": "sha256-tBi4D1tfACA4ogYMdyjUK8sDb370rAGE2q8baeJjpdA=", - "x86_64-darwin": "sha256-MPIai9M+EQps81qp7RBmTsW/qPjcrWd7GGJQyeNCz/U=" + "x86_64-linux": "sha256-Y64ujq2R4SiyOavsXmaYsQkVNfdjhkzmPolOEV1RVyc=", + "aarch64-linux": "sha256-7S209ir2j9o9NXYmMUnEfo6m64cUIkexd8L95UeCMFE=", + "aarch64-darwin": "sha256-bE1zw7EK+4rgUgw80t8hdc2yojAF2f8ihjorh6B7jJE=", + "x86_64-darwin": "sha256-xukM9cEvZAKCXewun9xReGBKz09i63LACLN/eH54020=" } } diff --git a/package.json b/package.json index 543649f503..2cbfccad00 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.1.22", + "@pierre/diffs": "1.2.10", "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", @@ -70,7 +70,7 @@ "@typescript/native-preview": "7.0.0-dev.20260316.1", "zod": "4.1.8", "remeda": "2.26.0", - "shiki": "3.20.0", + "shiki": "4.2.0", "solid-list": "0.3.0", "tailwindcss": "4.1.11", "virtua": "0.49.1", @@ -85,7 +85,9 @@ "@effect/sql-sqlite-bun": "4.0.0-beta.74", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", - "sst": "4.13.1" + "sst": "4.13.1", + "@tanstack/solid-virtual": "3.13.28", + "@shikijs/stream": "4.2.0" } }, "devDependencies": { @@ -157,11 +159,15 @@ "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@tanstack/solid-virtual@3.13.28": "patches/@tanstack%2Fsolid-virtual@3.13.28.patch", + "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", - "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@tanstack/virtual-core@3.17.0": "patches/@tanstack%2Fvirtual-core@3.17.0.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, diff --git a/packages/core/package.json b/packages/core/package.json index d5f73a3983..ddb4d33d35 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,8 +10,8 @@ "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", "test": "bun test --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "bin": { "opencode": "./bin/opencode" diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 82ab2b8213..4156db5c36 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -10,8 +10,7 @@ import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" -import { Credential } from "./credential" -import { IntegrationSchema } from "./integration/schema" +import { Integration } from "./integration" export type ProviderRecord = { provider: ProviderV2.Info @@ -35,12 +34,7 @@ export class ModelNotFoundError extends Schema.TaggedErrorClass) => { - const credential = active.get(IntegrationSchema.ID.make(provider.id)) - if (!credential) return provider - const body = { ...provider.request.body } - if (credential.value.type === "key") { - body.apiKey = credential.value.key - Object.assign(body, credential.value.metadata ?? {}) - } - // kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials - if (credential.value.type === "oauth") { - body.apiKey = credential.value.access - if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID - } - // kilocode_change end - return new ProviderV2.Info({ - ...provider, - enabled: { via: "credential", credentialID: credential.id }, - request: { ...provider.request, body }, - }) + const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined, connected: boolean) => { + if (provider.disabled) return false + if (typeof provider.request.body.apiKey === "string") return true + if (connected) return true + return !integration } - const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => { + const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 ? { ...provider.api, id: model.api.id } @@ -208,18 +188,16 @@ export const layer = Layer.effect( }, finalize: Effect.fn("CatalogV2.finalize")(function* (catalog, reason) { if (reason !== "plugin.added") yield* plugin.trigger("catalog.transform", catalog, {}).pipe(Effect.asVoid) - if (!policy.hasStatements()) return - for (const record of [...catalog.provider.list()]) { - if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { - catalog.provider.remove(record.provider.id) + if (policy.hasStatements()) { + for (const record of [...catalog.provider.list()]) { + if ((yield* policy.evaluate("provider.use", record.provider.id, "allow")) === "deny") { + catalog.provider.remove(record.provider.id) + } } } + yield* events.publish(Event.Updated, {}) }), }) - const active = Effect.fn("CatalogV2.active")(function* () { - return new Map((yield* credentials.all()).map((credential) => [credential.integrationID, credential])) - }) - yield* events.subscribe(PluginV2.Event.Added).pipe( // Plugin registries are location scoped even though the event bus is process scoped. Stream.filter( @@ -238,18 +216,23 @@ export const layer = Layer.effect( provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) - return project(record.provider, yield* active()) + return record.provider }), all: Effect.fn("CatalogV2.provider.all")(function* () { - const credentials = yield* active() - return Array.fromIterable(state.get().providers.values()).map((record) => - project(record.provider, credentials), - ) + return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return (yield* result.provider.all()).filter((provider) => provider.enabled) + const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration])) + const connections = yield* integrations.connection.list() + return (yield* result.provider.all()).filter((provider) => + available( + provider, + active.get(Integration.ID.make(provider.id)), + connections.has(Integration.ID.make(provider.id)), + ), + ) }), }, @@ -258,33 +241,32 @@ export const layer = Layer.effect( const record = yield* getRecord(providerID) const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return resolve(model, project(record.provider, yield* active())) + return projectModel(model, record.provider) }), all: Effect.fn("CatalogV2.model.all")(function* () { - const credentials = yield* active() return pipe( Array.fromIterable(state.get().providers.values()), Array.flatMap((record) => { - const provider = project(record.provider, credentials) - return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider)) + return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider)) }), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) }), available: Effect.fn("CatalogV2.model.available")(function* () { - const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) - return (yield* result.model.all()).filter( - (model) => providers.get(model.providerID)?.enabled !== false && model.enabled, - ) + const providers = new Set((yield* result.provider.available()).map((provider) => provider.id)) + return (yield* result.model.all()).filter((model) => providers.has(model.providerID) && model.enabled) }), default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) - if (Option.isSome(provider) && provider.value.enabled !== false) { + if ( + Option.isSome(provider) && + (yield* result.provider.available()).some((item) => item.id === provider.value.id) + ) { const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) if (Option.isSome(model) && model.value.enabled) return model } @@ -300,11 +282,11 @@ export const layer = Layer.effect( small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) if (!record) return Option.none() - const provider = project(record.provider, yield* active()) + const provider = record.provider if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(projectModel(gpt5Nano, provider)) } const candidates = pipe( @@ -332,7 +314,7 @@ export const layer = Layer.effect( return pipe( items, Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => resolve(item.model, provider)), + Array.map((item) => projectModel(item.model, provider)), Array.head, ) } @@ -353,6 +335,7 @@ export const layer = Layer.effect( const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/ export const locationLayer = layer.pipe( + Layer.provideMerge(Integration.locationLayer), Layer.provideMerge(PluginV2.locationLayer), Layer.provideMerge(Policy.locationLayer), ) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 0d31b32d08..47a3712e3a 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -3,6 +3,7 @@ export * as ConfigProviderPlugin from "./provider" import { Effect } from "effect" import { Catalog } from "../../catalog" import { Config } from "../../config" +import { Integration } from "../../integration" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginV2 } from "../../plugin" @@ -13,9 +14,33 @@ export const Plugin = PluginV2.define({ effect: Effect.gen(function* () { const catalog = yield* Catalog.Service const config = yield* Config.Service + const integrations = yield* Integration.Service const transform = yield* catalog.transform() + const integrationTransform = yield* integrations.transform() const entries = yield* config.entries() const files = entries.filter((entry): entry is Config.Document => entry.type === "document") + const configuredIntegrations = new Set( + files.flatMap((file) => + Object.entries(file.info.providers ?? {}).flatMap(([id, provider]) => (provider.env === undefined ? [] : [id])), + ), + ) + yield* integrationTransform((integrations) => { + for (const file of files) { + for (const [id, item] of Object.entries(file.info.providers ?? {})) { + const integrationID = Integration.ID.make(id) + if (!configuredIntegrations.has(id) && !integrations.get(integrationID)) continue + integrations.update(integrationID, (integration) => { + integration.name = item.name ?? integration.name + }) + if (item.env !== undefined) { + integrations.method.update({ + integrationID, + method: { type: "env", names: [...item.env] }, + }) + } + } + } + }) yield* transform((catalog) => { const configuredDefault = Config.latest(entries, "model") @@ -28,8 +53,6 @@ export const Plugin = PluginV2.define({ const providerID = ProviderV2.ID.make(id) catalog.provider.update(providerID, (provider) => { if (item.name !== undefined) provider.name = item.name - if (item.env !== undefined) provider.env = [...item.env] - provider.enabled = { via: "custom", data: {} } if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { Object.assign(provider.request.headers, item.request.headers) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts index 11a987fac3..6dc4bae1ee 100644 --- a/packages/core/src/credential.ts +++ b/packages/core/src/credential.ts @@ -102,6 +102,8 @@ export interface Interface { readonly all: () => Effect.Effect /** Returns stored credentials belonging to one integration. */ readonly list: (integrationID: IntegrationSchema.ID) => Effect.Effect + /** Returns one stored credential by ID. */ + readonly get: (id: ID) => Effect.Effect /** Replaces any credential for an integration and returns the new record. */ readonly create: (input: { readonly integrationID: IntegrationSchema.ID @@ -350,6 +352,11 @@ export const layer = Layer.effect( return credential ? [credential] : [] }) }), + get: Effect.fn("Credential.get")(function* (id) { + if (isolated) return find(id) // kilocode_change - injected workspace credentials are process-local + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + return row ? stored(row) : undefined + }), create: Effect.fn("Credential.create")(function* (input) { const credential = new Stored({ id: ID.create(), diff --git a/packages/core/src/integration.ts b/packages/core/src/integration.ts index 64053ee44c..7e007aa834 100644 --- a/packages/core/src/integration.ts +++ b/packages/core/src/integration.ts @@ -29,15 +29,16 @@ export const When = Schema.Struct({ }).annotate({ identifier: "Integration.When" }) export type When = typeof When.Type -export class TextPrompt extends Schema.Class("Integration.TextPrompt")({ +export const TextPrompt = Schema.Struct({ type: Schema.Literal("text"), key: Schema.String, message: Schema.String, placeholder: Schema.optional(Schema.String), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.TextPrompt" }) +export type TextPrompt = typeof TextPrompt.Type -export class SelectPrompt extends Schema.Class("Integration.SelectPrompt")({ +export const SelectPrompt = Schema.Struct({ type: Schema.Literal("select"), key: Schema.String, message: Schema.String, @@ -49,27 +50,31 @@ export class SelectPrompt extends Schema.Class("Integration.Select }), ), when: Schema.optional(When), -}) {} +}).annotate({ identifier: "Integration.SelectPrompt" }) +export type SelectPrompt = typeof SelectPrompt.Type export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) export type Prompt = typeof Prompt.Type -export class OAuthMethod extends Schema.Class("Integration.OAuthMethod")({ +export const OAuthMethod = Schema.Struct({ id: MethodID, type: Schema.Literal("oauth"), label: Schema.String, prompts: Schema.optional(Schema.Array(Prompt)), -}) {} +}).annotate({ identifier: "Integration.OAuthMethod" }) +export type OAuthMethod = typeof OAuthMethod.Type -export class KeyMethod extends Schema.Class("Integration.KeyMethod")({ +export const KeyMethod = Schema.Struct({ type: Schema.Literal("key"), label: Schema.optional(Schema.String), -}) {} +}).annotate({ identifier: "Integration.KeyMethod" }) +export type KeyMethod = typeof KeyMethod.Type -export class EnvMethod extends Schema.Class("Integration.EnvMethod")({ +export const EnvMethod = Schema.Struct({ type: Schema.Literal("env"), names: Schema.Array(Schema.String), -}) {} +}).annotate({ identifier: "Integration.EnvMethod" }) +export type EnvMethod = typeof EnvMethod.Type export const Method = Schema.Union([OAuthMethod, KeyMethod, EnvMethod]).pipe(Schema.toTaggedUnion("type")) export type Method = typeof Method.Type @@ -197,7 +202,11 @@ export interface Interface { readonly get: (id: ID) => Effect.Effect /** Returns all integrations with their methods and current connections. */ readonly list: () => Effect.Effect - readonly connect: { + readonly connection: { + /** Returns active connections for every registered or credential-backed integration. */ + readonly list: () => Effect.Effect> + /** Returns the active connection for one integration. */ + readonly forIntegration: (id: ID) => Effect.Effect /** Runs a key method and stores the resulting credential. */ readonly key: (input: { /** Integration receiving the credential. */ @@ -218,6 +227,13 @@ export interface Interface { /** User-facing label for the credential created on completion. */ readonly label?: string }) => Effect.Effect + /** Updates a stored credential exposed as a connection. */ + readonly update: ( + credentialID: Credential.ID, + updates: Partial>, + ) => Effect.Effect + /** Removes a stored credential connection. */ + readonly remove: (credentialID: Credential.ID) => Effect.Effect } readonly attempt: { /** Returns the current state of an OAuth attempt. */ @@ -328,23 +344,32 @@ export const locationLayer = Layer.effect( }) const connections = (entry: Entry, saved: readonly Credential.Stored[]): IntegrationConnection.Info[] => { - const connected = saved.map( - (credential) => - new IntegrationConnection.CredentialInfo({ type: "credential", id: credential.id, label: credential.label }), - ) + const connected = saved.map((credential) => ({ + type: "credential" as const, + id: credential.id, + label: credential.label, + })) const detected = entry.methods .filter((method) => method.type === "env") .flatMap((method) => method.names.filter((name) => process.env[name])) - .map( - (name, index) => - new IntegrationConnection.EnvInfo({ - type: "env", - name, - }), - ) + .map((name) => ({ type: "env" as const, name })) return [...connected, ...detected] } + const activeConnection = ( + entry: Entry | undefined, + saved: readonly Credential.Stored[], + ): IntegrationConnection.Info | undefined => { + const credential = saved.at(-1) + if (credential) return { type: "credential", id: credential.id, label: credential.label } + if (!entry) return + const name = entry.methods + .filter((method) => method.type === "env") + .flatMap((method) => method.names) + .find((name) => process.env[name]) + if (name) return { type: "env", name } + } + const project = (entry: Entry, saved: readonly Credential.Stored[]) => new Info({ id: entry.ref.id, @@ -412,6 +437,7 @@ export const locationLayer = Layer.effect( return [attempt, new Map(current).set(attemptID, terminal)] }) if (!result) return settled + if (Exit.isSuccess(settled)) yield* events.publish(Event.Updated, {}) yield* close(result.scope) return settled }), @@ -454,8 +480,21 @@ export const locationLayer = Layer.effect( }), )).toSorted((a, b) => a.name.localeCompare(b.name)) }), - connect: { - key: Effect.fn("Integration.connect.key")(function* (input) { + connection: { + list: Effect.fn("Integration.connection.list")(function* () { + const saved = Map.groupBy(yield* credentials.all(), (credential) => credential.integrationID) + return new Map( + new Set([...state.get().integrations.keys(), ...saved.keys()]).values().flatMap((id) => { + const connection = activeConnection(state.get().integrations.get(id), saved.get(id) ?? []) + return connection ? [[id, connection] as const] : [] + }), + ) + }), + forIntegration: Effect.fn("Integration.connection.forIntegration")(function* (id) { + const entry = state.get().integrations.get(id) + return activeConnection(entry, yield* credentials.list(id)) + }), + key: Effect.fn("Integration.connection.key")(function* (input) { const method = state .get() .integrations.get(input.integrationID) @@ -466,8 +505,9 @@ export const locationLayer = Layer.effect( label: input.label, value: new Credential.Key({ type: "key", key: input.key }), }) + yield* events.publish(Event.Updated, {}) }), - oauth: Effect.fn("Integration.connect.oauth")(function* (input) { + oauth: Effect.fn("Integration.connection.oauth")(function* (input) { const method = state.get().integrations.get(input.integrationID)?.implementations.get(input.methodID) if (!method) { return yield* Effect.die(`OAuth method not found: ${input.integrationID}/${input.methodID}`) @@ -510,6 +550,14 @@ export const locationLayer = Layer.effect( time, }) }), + update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) { + yield* credentials.update(credentialID, updates) + yield* events.publish(Event.Updated, {}) + }), + remove: Effect.fn("Integration.connection.remove")(function* (credentialID) { + yield* credentials.remove(credentialID) + yield* events.publish(Event.Updated, {}) + }), }, attempt: { status: Effect.fn("Integration.attempt.status")(function* (attemptID) { diff --git a/packages/core/src/integration/connection.ts b/packages/core/src/integration/connection.ts index a190ebf78c..200cf26580 100644 --- a/packages/core/src/integration/connection.ts +++ b/packages/core/src/integration/connection.ts @@ -3,16 +3,18 @@ export * as IntegrationConnection from "./connection" import { Schema } from "effect" import { Credential } from "../credential" -export class CredentialInfo extends Schema.Class("Connection.CredentialInfo")({ +export const CredentialInfo = Schema.Struct({ type: Schema.Literal("credential"), id: Credential.ID, label: Schema.String, -}) {} +}).annotate({ identifier: "Connection.CredentialInfo" }) +export type CredentialInfo = typeof CredentialInfo.Type -export class EnvInfo extends Schema.Class("Connection.EnvInfo")({ +export const EnvInfo = Schema.Struct({ type: Schema.Literal("env"), name: Schema.String, -}) {} +}).annotate({ identifier: "Connection.EnvInfo" }) +export type EnvInfo = typeof EnvInfo.Type export const Info = Schema.Union([CredentialInfo, EnvInfo]) .pipe(Schema.toTaggedUnion("type")) diff --git a/packages/core/src/kilocode/powershell.ts b/packages/core/src/kilocode/powershell.ts new file mode 100644 index 0000000000..866f215e04 --- /dev/null +++ b/packages/core/src/kilocode/powershell.ts @@ -0,0 +1,126 @@ +export function args(command: string) { + return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] +} + +const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); +$OutputEncoding = [Console]::OutputEncoding; +` + +function script(command: string) { + const pos = prologue(command) + const head = command.slice(0, pos) + const body = command.slice(pos) + const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : "" + return `${head}${gap}${setup}${body}` +} + +function prologue(command: string) { + const pos = scan(command, 0) + const attr = attrs(command, pos) + const body = command.slice(attr) + const match = /^param\s*\(/i.exec(body) + if (!match) return pos + + const start = attr + match[0].lastIndexOf("(") + const end = block(command, start, "(", ")") + if (end === undefined) return pos + return end +} + +function attrs(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = scan(command, pos) + if (command[next] !== "[") return next + const end = block(command, next, "[", "]") + if (end === undefined) return start + pos = end + } + return pos +} + +function scan(command: string, start: number) { + let pos = start + while (pos < command.length) { + const next = trivia(command, pos) + if (next !== pos) { + pos = next + continue + } + const end = line(command, pos) + const value = command.slice(pos, end) + if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) { + pos = end + continue + } + return pos + } + return pos +} + +function trivia(command: string, start: number) { + let pos = start + while (pos < command.length) { + while (/\s/.test(command[pos] ?? "")) pos++ + if (command[pos] === "#") { + pos = line(command, pos) + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return command.length + pos = end + 2 + continue + } + return pos + } + return pos +} + +function line(command: string, start: number) { + const index = command.indexOf("\n", start) + if (index === -1) return command.length + return index + 1 +} + +function block(command: string, start: number, open: string, close: string) { + let depth = 0 + let quote: string | undefined + for (let pos = start; pos < command.length; pos++) { + const char = command[pos] + if (quote) { + if (quote === "'" && char === "'" && command[pos + 1] === "'") { + pos++ + continue + } + if (quote === '"' && char === "`") { + pos++ + continue + } + if (char === quote) quote = undefined + continue + } + if (char === "'" || char === '"') { + quote = char + continue + } + if (command.startsWith("<#", pos)) { + const end = command.indexOf("#>", pos + 2) + if (end === -1) return + pos = end + 1 + continue + } + if (char === "#") { + pos = line(command, pos) - 1 + continue + } + if (char === open) depth++ + if (char === close) { + depth-- + if (depth === 0) return pos + 1 + } + } +} + +export const PowerShell = { args } diff --git a/packages/core/src/kilocode/pty-self-command.ts b/packages/core/src/kilocode/pty-self-command.ts new file mode 100644 index 0000000000..360e094bda --- /dev/null +++ b/packages/core/src/kilocode/pty-self-command.ts @@ -0,0 +1,61 @@ +import path from "path" + +type Input = { + command?: string + args?: string[] + cwd?: string +} + +type Command = { + command: string + args: string[] + cwd?: string +} + +const names = new Set(["kilo", "kilocode"]) +const self = command() + +function clean(input: string[]) { + return input.filter((arg, index) => { + if (arg === "--cwd") return false + if (input[index - 1] === "--cwd") return false + if (arg.startsWith("--cwd=")) return false + return true + }) +} + +function full(input: string, cwd: string) { + if (path.isAbsolute(input)) return input + return path.resolve(cwd, input) +} + +export function command( + proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() }, +): Command { + const script = proc.argv[1] + const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false) + if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) { + const file = full(script, proc.cwd) + const dir = path.dirname(file) + const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd + return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root } + } + return { command: full(proc.execPath, proc.cwd), args: [] } +} + +export function resolve(input: Input, cmd = self): Input { + if (!input.command || !names.has(input.command)) return input + const args = input.args ?? [] + const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : [] + return { + ...input, + command: cmd.command, + args: [...cmd.args, ...project, ...args], + cwd: cmd.cwd ?? input.cwd, + } +} + +export const KiloPtySelfCommand = { + command, + resolve, +} diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index ebfb096f13..9225e4a01a 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer, Schema } from "effect" import { Project } from "./project" -import { AbsolutePath } from "./schema" +import { AbsolutePath, optionalOmitUndefined } from "./schema" import { WorkspaceV2 } from "./workspace" export * as Location from "./location" @@ -12,7 +12,7 @@ export class Ref extends Schema.Class("Location.Ref")({ export class Info extends Schema.Class("Location.Info")({ directory: AbsolutePath, - workspaceID: WorkspaceV2.ID.pipe(Schema.optional), + workspaceID: optionalOmitUndefined(WorkspaceV2.ID), project: Schema.Struct({ id: Project.ID, directory: AbsolutePath, diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index 7578271409..694b0bc564 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -21,7 +21,6 @@ import { PluginV2 } from "../plugin" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ConfigProviderPlugin } from "../config/plugin/provider" -import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" @@ -98,7 +97,6 @@ export const layer = Layer.effect( }) const boot = Effect.gen(function* () { - yield* add(EnvPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. diff --git a/packages/core/src/plugin/env.ts b/packages/core/src/plugin/env.ts deleted file mode 100644 index 35e6981a40..0000000000 --- a/packages/core/src/plugin/env.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Effect } from "effect" -import { PluginV2 } from "../plugin" - -export const EnvPlugin = PluginV2.define({ - id: PluginV2.ID.make("env"), - effect: Effect.gen(function* () { - return { - "catalog.transform": Effect.fn(function* (evt) { - for (const item of evt.provider.list()) { - const key = item.provider.env.find((env) => process.env[env]) - if (!key) continue - evt.provider.update(item.provider.id, (provider) => { - provider.enabled = { - via: "env", - name: key, - } - }) - } - }), - } - }), -}) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index e34c11f788..a212d013ad 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -70,16 +70,11 @@ export const ModelsDevPlugin = PluginV2.define({ integrations.update(integrationID, (integration) => (integration.name = item.name)) integrations.method.update({ integrationID, - method: new Integration.KeyMethod({ - type: "key", - }), + method: { type: "key" }, }) integrations.method.update({ integrationID, - method: new Integration.EnvMethod({ - type: "env", - names: [...item.env], - }), + method: { type: "env", names: [...item.env] }, }) } }) @@ -88,7 +83,6 @@ export const ModelsDevPlugin = PluginV2.define({ const providerID = ProviderV2.ID.make(item.id) catalog.provider.update(providerID, (provider) => { provider.name = item.name - provider.env = [...item.env] provider.api = item.npm ? { type: "aisdk", diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index afb3a183e5..ba7856b635 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -24,7 +24,7 @@ export const CloudflareAIGatewayPlugin = PluginV2.define({ apiKey: config.apiKey, options: gatewayOptions(evt.options, metadata), } as any) - const unified = createUnified() + const unified = createUnified({ apiKey: config.apiKey }) evt.sdk = { languageModel(modelID: string) { return gateway(unified(modelID)) diff --git a/packages/core/src/plugin/provider/kilo.ts b/packages/core/src/plugin/provider/kilo.ts index 78fdda1995..bf6666c252 100644 --- a/packages/core/src/plugin/provider/kilo.ts +++ b/packages/core/src/plugin/provider/kilo.ts @@ -27,9 +27,9 @@ export const KiloPlugin = PluginV2.define({ provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start provider.request.headers["X-Title"] = "Kilo Code" - options.kilocodeToken = token ?? "anonymous" + options.apiKey = token ?? "anonymous" + options.kilocodeToken = options.apiKey if (org) options.kilocodeOrganizationId = org - if (!provider.enabled) provider.enabled = { via: "custom", data: { anonymous: true } } // kilocode_change end }) } diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index d8769aa980..b416abd284 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -1,20 +1,23 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" // kilocode_change export const LLMGatewayPlugin = PluginV2.define({ id: PluginV2.ID.make("llmgateway"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service return { "catalog.transform": Effect.fn(function* (evt) { for (const item of evt.provider.list()) { - if (item.provider.enabled === false) continue + if (item.provider.disabled) continue + if (!(yield* integrations.get(Integration.ID.make(item.provider.id)))) continue if (item.provider.api.type !== "aisdk") continue if (item.provider.api.package !== "@ai-sdk/openai-compatible") continue if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change evt.provider.update(item.provider.id, (provider) => { - provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change + provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change start provider.request.headers["X-Title"] = "Kilo Code" provider.request.headers["X-Source"] = "kilo" diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts index c28757ebdf..fb9e8d300a 100644 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -32,11 +32,11 @@ const headlessMethodID = Integration.MethodID.make("chatgpt-headless") export const browser = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: browserMethodID, type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), + }, authorize: () => Effect.gen(function* () { const pkce = yield* Effect.promise(generatePKCE) @@ -95,11 +95,11 @@ export const browser = { export const headless = { integrationID: Integration.ID.make("openai"), - method: new Integration.OAuthMethod({ + method: { id: headlessMethodID, type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, authorize: () => Effect.gen(function* () { const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( @@ -258,6 +258,6 @@ function claim(token: string) { } const successPage = - "Kilo

Authorization successful

You can close this window.

" // kilocode_change + "Kilo

Authorization successful

You can close this window.

" const errorPage = (message: string) => - `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` // kilocode_change + `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 67fd7816a3..56e71f822d 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,20 +1,20 @@ import { Effect } from "effect" +import { Integration } from "../../integration" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" export const OpencodePlugin = PluginV2.define({ id: PluginV2.ID.make("opencode"), effect: Effect.gen(function* () { + const integrations = yield* Integration.Service let hasKey = false return { "catalog.transform": Effect.fn(function* (evt) { const item = evt.provider.get(ProviderV2.ID.opencode) if (!item) return + const integration = yield* integrations.get(Integration.ID.make(item.provider.id)) hasKey = Boolean( - process.env.OPENCODE_API_KEY || - item.provider.env.some((env) => process.env[env]) || - item.provider.request.body.apiKey || - (item.provider.enabled && item.provider.enabled.via === "credential"), + process.env.OPENCODE_API_KEY || integration?.connections.length || item.provider.request.body.apiKey, ) evt.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 7c89ac8e33..620fdc8b9a 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -23,7 +23,7 @@ export const Plugin = PluginV2.define({ skill: new SkillV2.Info({ name: "customize-opencode", description: - "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", + "Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, commands, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself.", location: AbsolutePath.make("/builtin/customize-opencode.md"), content: CustomizeOpencodeContent, }), diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 1c1cbdf3c2..f5235cc23f 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -43,6 +43,8 @@ already-loaded config until then. | Global config | `~/.config/opencode/opencode.json` (NOT `~/.opencode/`) | | Project agents | `.opencode/agent/.md` or `.opencode/agents/.md` | | Global agents | `~/.config/opencode/agent(s)/.md` | +| Project commands | `.opencode/command/.md` or `.opencode/commands/.md` | +| Global commands | `~/.config/opencode/command(s)/.md` | | Project skills | `.opencode/skill(s)//SKILL.md` | | Global skills | `~/.config/opencode/skill(s)//SKILL.md` | | External skills (auto-loaded) | `~/.claude/skills//SKILL.md`, `~/.agents/skills//SKILL.md` | @@ -96,7 +98,7 @@ Every field is optional. }, "command": { - "deploy": { "description": "...", "prompt": "..." } + "deploy": { "description": "...", "template": "..." } }, "provider": { @@ -151,6 +153,7 @@ Shape notes worth being explicit about: - `skills` is an object with `paths` and/or `urls`, not an array. - `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. - `agent` is an object keyed by agent name, not an array. +- `command` is an object keyed by command name, not an array. - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. - `permission` is either a string action or an object keyed by tool name. @@ -277,6 +280,31 @@ opencode ships with `build`, `plan`, `general`, `explore`. Hidden internal agent `compaction`, `title`, `summary`. To override a built-in's fields, define the same key in `agent: { : { ... } }`. +## Commands + +opencode's command loader scans for `**/*.md` inside command directories. The +file is named after the command, and lives directly inside the `command` folder: + +``` +.opencode/command/deploy.md +``` + +Frontmatter: + +```markdown +--- +description: One sentence describing what the command does. +agent: build +model: anthropic/claude-sonnet-4-6 +--- + +(command body in markdown: the prompt opencode runs, with $ARGUMENTS for the user's input) +``` + +- `template` is the command body — everything below the frontmatter — and is required: it is the prompt opencode runs when the command is invoked. Do not also put a `template:` key in the frontmatter. +- `$ARGUMENTS` is replaced with everything the user typed after the command; `$1`, `$2`, … pull individual positional arguments. +- Optional: `description`, `agent`, `model`, `variant`, `subtask`. + ## Plugins `plugin:` is an array. Each entry is one of: @@ -300,7 +328,7 @@ function, not a plain object literal, and the function returns an object (return `{}` if there is nothing to register). ```ts -import type { Plugin } from "@opencode-ai/plugin" +import type { Plugin } from "@kilocode/plugin" export default (async ({ client, project, directory, $ }) => { return { @@ -397,16 +425,16 @@ the `plan` agent's permission ruleset (`edit: deny *`). When a user's config is broken and opencode won't start, these env vars help: -- `OPENCODE_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` +- `KILO_DISABLE_PROJECT_CONFIG=1`: skip the project's local `opencode.json` and start from globals only. Run from the project directory, opencode loads, the user edits the broken file, then they restart without the flag. -- `OPENCODE_CONFIG=/path/to/file.json`: load an additional explicit config. -- `OPENCODE_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: +- `KILO_CONFIG=/path/to/file.json`: load an additional explicit config. +- `KILO_CONFIG_CONTENT='{"$schema":"https://opencode.ai/config.json"}'`: inject inline JSON as a final local-scope merge. -- `OPENCODE_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. -- `OPENCODE_PURE=1`: skip external plugins entirely. -- `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, - `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under +- `KILO_DISABLE_DEFAULT_PLUGINS=1`: skip default plugins. +- `KILO_PURE=1`: skip external plugins entirely. +- `KILO_DISABLE_EXTERNAL_SKILLS=1`, + `KILO_DISABLE_CLAUDE_CODE_SKILLS=1`: skip the external skill scans under `~/.claude/` and `~/.agents/`. ## When proposing edits @@ -415,8 +443,8 @@ When a user's config is broken and opencode won't start, these env vars help: exact shape, or the field is not covered in this skill, fetch `https://opencode.ai/config.json` and read the schema rather than guessing. - Preserve `$schema` and any existing fields the user did not ask to change. -- For agent, skill, and plugin definitions, prefer creating new files in the - correct location over inlining everything in `opencode.json`. +- For agent, command, skill, and plugin definitions, prefer creating new files + in the correct location over inlining everything in `opencode.json`. - If the user's existing config is malformed, point them at the env-var escape hatches above so they can edit from inside opencode without breaking their session. diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 670b0d2dcb..0e3246b3b2 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -245,6 +245,7 @@ export const layer = Layer.effect( (sourceDirectory) => Effect.forEach(strategies(), (strategy) => strategy.list(sourceDirectory).pipe( + Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])), Effect.map((items) => items.map((item) => ({ directory: item.directory, diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 58b5e9f813..0d6084257b 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -2,7 +2,6 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" import { Schema } from "effect" -import { Credential } from "./credential" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -49,22 +48,7 @@ export type Request = typeof Request.Type export class Info extends Schema.Class("ProviderV2.Info")({ id: ID, name: Schema.String, - enabled: Schema.Union([ - Schema.Literal(false), - Schema.Struct({ - via: Schema.Literal("env"), - name: Schema.String, - }), - Schema.Struct({ - via: Schema.Literal("credential"), - credentialID: Credential.ID, - }), - Schema.Struct({ - via: Schema.Literal("custom"), - data: Schema.Record(Schema.String, Schema.Any), - }), - ]), - env: Schema.String.pipe(Schema.Array), + disabled: Schema.Boolean.pipe(Schema.optional), api: Api, request: Request, }) { @@ -72,8 +56,6 @@ export class Info extends Schema.Class("ProviderV2.Info")({ return new Info({ id: providerID, name: providerID, - enabled: false, - env: [], api: { type: "native", settings: {}, diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 79ddc99b63..28e48f6b05 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -2,23 +2,29 @@ export * as Pty from "./pty" import type { Disp, Proc } from "#pty" import { Context, Effect, Layer, Schema, Types } from "effect" +import { Config } from "./config" import { EventV2 } from "./event" import { Location } from "./location" import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { SessionSchema } from "./session/schema" // kilocode_change +import { Shell } from "./shell" import { lazy } from "./util/lazy" +import { KiloPtySelfCommand } from "./kilocode/pty-self-command" // kilocode_change const BUFFER_LIMIT = 1024 * 1024 * 2 -const BUFFER_CHUNK = 64 * 1024 -const encoder = new TextEncoder() +// Exited sessions stay observable (status, exit code, retained output) until removed explicitly. +// Cap retention so abandoned terminals do not accumulate unbounded buffers. +const EXITED_LIMIT = 25 const pty = lazy(() => import("#pty")) -type Socket = { - readyState: number - data?: unknown - send: (data: string | Uint8Array | ArrayBuffer) => void - close: (code?: number, reason?: string) => void +type Subscriber = { + readonly onData: (chunk: string) => void + readonly onEnd: (event: { exitCode?: number }) => void + active: boolean + detached: boolean + pending: string[] + end?: { exitCode?: number } } type Active = { @@ -27,22 +33,10 @@ type Active = { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map listeners: Disp[] } -const sock = (ws: Socket) => (ws.data && typeof ws.data === "object" ? ws.data : ws) - -// WebSocket control frame: 0x00 + UTF-8 JSON. -const meta = (cursor: number) => { - const json = JSON.stringify({ cursor }) - const bytes = encoder.encode(json) - const out = new Uint8Array(bytes.length + 1) - out[0] = 0 - out.set(bytes, 1) - return out -} - export const Info = Schema.Struct({ id: PtyID, title: Schema.String, @@ -52,6 +46,8 @@ export const Info = Schema.Struct({ status: Schema.Literals(["running", "exited"]), // Windows ConPTY assigns the child pid asynchronously, so 0 is valid at spawn time. pid: NonNegativeInt, + // Present once status is "exited". + exitCode: Schema.optional(NonNegativeInt), sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change }).annotate({ identifier: "Pty" }) @@ -67,14 +63,6 @@ export const CreateInput = Schema.Struct({ export type CreateInput = Types.DeepMutable -export type PreparedCreate = { - readonly command: string - readonly args: string[] - readonly cwd: string - readonly title?: string - readonly env: Record -} - export const UpdateInput = Schema.Struct({ title: Schema.optional(Schema.String), sessionID: Schema.optional(Schema.NullOr(SessionSchema.ID)), // kilocode_change @@ -88,10 +76,34 @@ export const UpdateInput = Schema.Struct({ export type UpdateInput = Types.DeepMutable +export type AttachInput = { + // Absolute output cursor to replay from. -1 tails from the current end; omitted replays the full retained buffer. + readonly cursor?: number + // Callbacks fire synchronously from the native PTY data path; keep them non-blocking. + readonly onData: (chunk: string) => void + // Fired once when the session stops producing output: process exit (exitCode set), removal, or service teardown. + readonly onEnd: (event: { exitCode?: number }) => void +} + +export type Attachment = { + // Retained output from the requested cursor to the current end. + readonly replay: string + // Absolute output cursor after replay. + readonly cursor: number + readonly write: (data: string) => void + // Starts live delivery after the caller has applied replay and cursor metadata. + readonly activate: () => void + readonly detach: () => void +} + export class NotFoundError extends Schema.TaggedErrorClass()("Pty.NotFoundError", { ptyID: PtyID, }) {} +export class ExitedError extends Schema.TaggedErrorClass()("Pty.ExitedError", { + ptyID: PtyID, +}) {} + export const Event = { Created: EventV2.define({ type: "pty.created", schema: { info: Info } }), Updated: EventV2.define({ type: "pty.updated", schema: { info: Info } }), @@ -102,19 +114,11 @@ export const Event = { export interface Interface { readonly list: () => Effect.Effect readonly get: (id: PtyID) => Effect.Effect - readonly create: (input: PreparedCreate) => Effect.Effect + readonly create: (input: CreateInput) => Effect.Effect readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect readonly remove: (id: PtyID) => Effect.Effect - readonly resize: (id: PtyID, cols: number, rows: number) => Effect.Effect readonly write: (id: PtyID, data: string) => Effect.Effect - readonly connect: ( - id: PtyID, - ws: Socket, - cursor?: number, - ) => Effect.Effect< - { onMessage: (message: string | ArrayBuffer) => void; onClose: () => void } | undefined, - NotFoundError - > + readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Pty") {} @@ -124,28 +128,41 @@ export const layer = Layer.effect( Effect.gen(function* () { const events = yield* EventV2.Service const location = yield* Location.Service + const config = yield* Config.Service const context = yield* Effect.context() const runFork = Effect.runForkWith(context) const sessions = new Map() + const exitOrder: PtyID[] = [] + + function notifyEnd(session: Active, event: { exitCode?: number }) { + for (const subscriber of session.subscribers.values()) { + if (!subscriber.active) { + subscriber.end = event + continue + } + try { + subscriber.onEnd(event) + } catch {} + } + session.subscribers.clear() + } function teardown(session: Active) { for (const listener of session.listeners) listener.dispose() session.listeners.length = 0 - try { - session.process.kill() - } catch {} - for (const [sub, ws] of session.subscribers.entries()) { + if (session.info.status === "running") { try { - if (sock(ws) === sub) ws.close() + session.process.kill() } catch {} } - session.subscribers.clear() + notifyEnd(session, {}) } yield* Effect.addFinalizer(() => Effect.sync(() => { for (const session of sessions.values()) teardown(session) sessions.clear() + exitOrder.length = 0 }), ) @@ -157,12 +174,13 @@ export const layer = Layer.effect( const removeSession = Effect.fnUntraced(function* (id: PtyID) { const session = sessions.get(id) - if (!session) return false + if (!session) return sessions.delete(id) + const index = exitOrder.indexOf(id) + if (index !== -1) exitOrder.splice(index, 1) yield* Effect.logInfo("removing session", { id }) teardown(session) yield* events.publish(Event.Deleted, { id: session.info.id }) - return true }) const remove = Effect.fn("Pty.remove")(function* (id: PtyID) { @@ -178,29 +196,48 @@ export const layer = Layer.effect( return (yield* requireSession(id)).info }) - const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) { + const create = Effect.fn("Pty.create")(function* (input: CreateInput) { const id = PtyID.ascending() - yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) + // kilocode_change start - resolve Kilo self-commands to the real binary, arguments, and project cwd + const resolved = KiloPtySelfCommand.resolve({ + command: input.command, + args: input.args ? [...input.args] : undefined, + cwd: input.cwd, + }) + const command = resolved.command || Shell.preferred(Config.latest(yield* config.entries(), "shell")) + const base = resolved.args ?? [] + const args = Shell.login(command) ? [...base, "-l"] : [...base] + const cwd = resolved.cwd || location.directory + // kilocode_change end + const env = { + ...process.env, + ...input.env, + TERM: "xterm-256color", + KILO_TERMINAL: "1", + KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal + } as Record + // kilocode_change start - do not expose local server credentials to user terminals. + // node-pty inherits parent values for omitted keys, so empty tombstones are required. + env.KILO_SERVER_PASSWORD = "" + env.KILO_SERVER_USERNAME = "" + // kilocode_change end + if (process.platform === "win32") { + env.LC_ALL = "C.UTF-8" + env.LC_CTYPE = "C.UTF-8" + env.LANG = "C.UTF-8" + } + yield* Effect.logInfo("creating session", { id, cmd: command, args, cwd }) const { spawn } = yield* Effect.promise(() => pty()) - // kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can - // detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID) - const env = { ...input.env, KILO_PTY_ID: id } - const proc = yield* Effect.sync(() => - spawn(input.command, input.args, { - name: "xterm-256color", - cwd: input.cwd, - env, - }), - ) - const info = { + const proc = yield* Effect.sync(() => spawn(command, args, { name: "xterm-256color", cwd, env })) + const info: Info = { id, title: input.title || `Terminal ${id.slice(-4)}`, - command: input.command, - args: input.args, - cwd: input.cwd, + command, + args, + cwd, status: "running", pid: proc.pid, - } as const + } const session: Active = { info, process: proc, @@ -214,15 +251,15 @@ export const layer = Layer.effect( session.listeners.push( proc.onData((chunk) => { session.cursor += chunk.length - for (const [key, ws] of session.subscribers.entries()) { - if (ws.readyState !== 1 || sock(ws) !== key) { - session.subscribers.delete(key) + for (const [token, subscriber] of session.subscribers.entries()) { + if (!subscriber.active) { + subscriber.pending.push(chunk) continue } try { - ws.send(chunk) + subscriber.onData(chunk) } catch { - session.subscribers.delete(key) + session.subscribers.delete(token) } } session.buffer += chunk @@ -233,12 +270,19 @@ export const layer = Layer.effect( }), proc.onExit(({ exitCode }) => { if (session.info.status === "exited") return + session.info.status = "exited" + session.info.exitCode = exitCode + notifyEnd(session, { exitCode }) + exitOrder.push(id) runFork( Effect.gen(function* () { yield* Effect.logInfo("session exited", { id, exitCode }) - session.info.status = "exited" yield* events.publish(Event.Exited, { id, exitCode }) - yield* removeSession(id) + while (exitOrder.length > EXITED_LIMIT) { + const oldest = exitOrder[0] + if (!oldest) break + yield* removeSession(oldest) + } }), ) }), @@ -253,66 +297,71 @@ export const layer = Layer.effect( // kilocode_change start - associate nested Kilo TUI terminals with the viewed session if ("sessionID" in input) session.info.sessionID = input.sessionID ?? undefined // kilocode_change end - if (input.size) session.process.resize(input.size.cols, input.size.rows) + if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows) yield* events.publish(Event.Updated, { info: session.info }) return session.info }) - const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) { - const session = yield* requireSession(id) - if (session.info.status === "running") session.process.resize(cols, rows) - }) - const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) { const session = yield* requireSession(id) if (session.info.status === "running") session.process.write(data) }) - const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { - const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close()))) - yield* Effect.logInfo("client connected to session", { id, directory: location.directory }) - const sub = sock(ws) - session.subscribers.delete(sub) - session.subscribers.set(sub, ws) - const cleanup = () => session.subscribers.delete(sub) + const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) { + const session = yield* requireSession(id) + if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id }) + yield* Effect.logInfo("client attached to session", { id, directory: location.directory }) + const token = {} + const subscriber: Subscriber = { + onData: input.onData, + onEnd: input.onEnd, + active: false, + detached: false, + pending: [], + } + session.subscribers.set(token, subscriber) const start = session.bufferCursor const end = session.cursor const from = - cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 - const data = (() => { + input.cursor === -1 + ? end + : typeof input.cursor === "number" && Number.isSafeInteger(input.cursor) + ? Math.max(0, input.cursor) + : 0 + const replay = (() => { if (!session.buffer || from >= end) return "" const offset = Math.max(0, from - start) if (offset >= session.buffer.length) return "" return session.buffer.slice(offset) })() - if (data) { - try { - for (let i = 0; i < data.length; i += BUFFER_CHUNK) ws.send(data.slice(i, i + BUFFER_CHUNK)) - } catch { - cleanup() - ws.close() - return - } - } - try { - ws.send(meta(end)) - } catch { - cleanup() - ws.close() - return - } return { - onMessage: (message: string | ArrayBuffer) => { - session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) + replay, + cursor: end, + write: (data: string) => { + if (session.info.status === "running") session.process.write(data) }, - onClose: () => { - cleanup() + activate: () => { + if (subscriber.active || subscriber.detached) return + subscriber.active = true + try { + for (const chunk of subscriber.pending) subscriber.onData(chunk) + subscriber.pending.length = 0 + if (subscriber.end) subscriber.onEnd(subscriber.end) + } catch { + session.subscribers.delete(token) + } + }, + detach: () => { + subscriber.detached = true + subscriber.pending.length = 0 + subscriber.end = undefined + session.subscribers.delete(token) }, } }) - return Service.of({ list, get, create, update, remove, resize, write, connect }) + return Service.of({ list, get, create, update, remove, write, attach }) }), ) -export const locationLayer = layer +export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) diff --git a/packages/core/src/pty/input.ts b/packages/core/src/pty/input.ts deleted file mode 100644 index 0e4ea9a61a..0000000000 --- a/packages/core/src/pty/input.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Effect } from "effect" - -const inputDecoder = new TextDecoder("utf-8", { fatal: true }) - -export function handlePtyInput( - handler: { onMessage: (message: string | ArrayBuffer) => void }, - message: string | Uint8Array, -) { - if (typeof message === "string") { - handler.onMessage(message) - return Effect.void - } - return Effect.try({ - try: () => inputDecoder.decode(message), - catch: () => new Error("invalid PTY websocket input"), - }).pipe( - Effect.catch(() => Effect.succeed(undefined)), - Effect.flatMap((decoded) => { - if (decoded === undefined) return Effect.void - handler.onMessage(decoded) - return Effect.void - }), - ) -} diff --git a/packages/core/src/pty/protocol.ts b/packages/core/src/pty/protocol.ts new file mode 100644 index 0000000000..21c6a89f73 --- /dev/null +++ b/packages/core/src/pty/protocol.ts @@ -0,0 +1,37 @@ +export * as PtyProtocol from "./protocol" + +// Wire protocol for PTY websocket transports. The PTY domain service is transport-free; server +// routes adapt Pty.attach to websockets with these helpers so every surface speaks one protocol. +// +// Outbound frames are raw UTF-8 terminal chunks. One control frame — a 0x00 byte followed by +// UTF-8 JSON — carries the absolute output cursor after replay so clients can resume later. + +const encoder = new TextEncoder() +const decoder = new TextDecoder("utf-8", { fatal: true }) + +// Replay can be megabytes; send it in bounded frames. +export const REPLAY_CHUNK = 64 * 1024 + +export function metaFrame(cursor: number) { + const bytes = encoder.encode(JSON.stringify({ cursor })) + const out = new Uint8Array(bytes.length + 1) + out[0] = 0 + out.set(bytes, 1) + return out +} + +export function chunks(data: string) { + const out: string[] = [] + for (let i = 0; i < data.length; i += REPLAY_CHUNK) out.push(data.slice(i, i + REPLAY_CHUNK)) + return out +} + +// Inbound client frames are UTF-8 text or binary; invalid UTF-8 input is dropped. +export function decodeInput(message: string | Uint8Array | ArrayBuffer) { + if (typeof message === "string") return message + try { + return decoder.decode(message instanceof ArrayBuffer ? new Uint8Array(message) : message) + } catch { + return undefined + } +} diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 67a110413e..4060cc6b04 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -1,7 +1,7 @@ export * as SessionRunner from "./index" import type { LLMError } from "@opencode-ai/llm" -import { Context, Effect, Schema } from "effect" +import { Context, Effect } from "effect" import { SessionSchema } from "../schema" import type { ContextSnapshotDecodeError, MessageDecodeError } from "../error" import { SessionRunnerModel } from "./model" @@ -9,20 +9,11 @@ import type { SystemContext } from "../../system-context/index" import type { SessionContextEpoch } from "../context-epoch" import type { ToolOutputStore } from "../../tool-output-store" -export class StepLimitExceededError extends Schema.TaggedErrorClass()( - "SessionRunner.StepLimitExceededError", - { - sessionID: SessionSchema.ID, - limit: Schema.Int, - }, -) {} - export type RunError = | LLMError | SessionRunnerModel.Error | MessageDecodeError | ContextSnapshotDecodeError - | StepLimitExceededError | SystemContext.InitializationBlocked | SessionContextEpoch.AgentReplacementBlocked | ToolOutputStore.Error diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 02a1eb3fed..233a4aa4d8 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -3,6 +3,7 @@ import { LLMClient, LLMError, LLMEvent, + Message, SystemPart, isContextOverflowFailure, type ProviderErrorEvent, @@ -29,10 +30,11 @@ import { SessionHistory } from "../history" import { SessionInput } from "../input" import { SessionSchema } from "../schema" import { SessionStore } from "../store" -import { type RunError, Service, StepLimitExceededError } from "./index" +import { type RunError, Service } from "./index" import { SessionRunnerModel } from "./model" import { createLLMEventPublisher } from "./publish-llm-event" import { toLLMMessages } from "./to-llm-message" +import { MAX_STEPS_PROMPT } from "./max-steps" /** * Runs one durable coding-agent Session until it settles. @@ -45,7 +47,7 @@ import { toLLMMessages } from "./to-llm-message" * - [ ] Replace local ownership with durable multi-node ownership when clustered. * - [ ] Mark busy, retrying, idle, interrupted, or terminal-failure status durably. * - [ ] Honor interruption and reject stale work after runtime attachment replacement. - * - [x] Bound model steps. + * - [x] Honor optional agent step limits. * - [ ] Bound provider retries and repeated identical tool calls. * * - Runtime context assembly @@ -80,13 +82,10 @@ import { toLLMMessages } from "./to-llm-message" * Durable activity recovery remains a separate future slice with an explicit retry policy. * * The current slice loads V2 history, translates it, resolves a model through a core service, and persists one - * provider turn. Registry definitions are advertised, local tool calls are settled durably, and a - * bounded explicit loop starts the next provider turn after local settlement. + * provider turn. Registry definitions are advertised, local tool calls are settled durably, and an + * explicit loop starts the next provider turn after local settlement. Configured agent step limits bound the loop. */ -// QUESTION: Did this exist previously, or did we add this limit? Does it make sense? -const MAX_STEPS = 25 - export const layer = Layer.effect( Service, Effect.gen(function* () { @@ -175,6 +174,7 @@ export const layer = Layer.effect( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, recoverOverflow?: typeof compaction.compactAfterOverflow, ) { const session = yield* getSession(sessionID) @@ -214,7 +214,8 @@ export const layer = Layer.effect( const model = yield* models.resolve(session) const entries = yield* SessionHistory.entriesForRunner(db, session.id, system.baselineSeq) const context = entries.map((entry) => entry.message) - const toolMaterialization = yield* tools.materialize(agent.info?.permissions) + const isLastStep = agent.info?.steps !== undefined && step >= agent.info.steps + const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions) const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id const request = LLM.request({ model, @@ -222,8 +223,9 @@ export const layer = Layer.effect( system: [agent.info?.system, system.baseline] .filter((part): part is string => part !== undefined && part.length > 0) .map(SystemPart.make), - messages: toLLMMessages(context, model), - tools: toolMaterialization.definitions, + messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])], + tools: toolMaterialization?.definitions ?? [], + toolChoice: isLastStep ? "none" : undefined, }) if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request })) return yield* Effect.die(rebuildPreparedTurn()) @@ -254,6 +256,10 @@ export const layer = Layer.effect( } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return + if (!toolMaterialization) { + yield* withPublication(publisher.failUnsettledTools("Tools are disabled after the maximum agent steps")) + return + } needsContinuation = true const assistantMessageID = yield* publisher.assistantMessageID(event.id) yield* Effect.uninterruptibleMask((restore) => @@ -340,31 +346,32 @@ export const layer = Layer.effect( type RunTurn = ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + step: number, ) => Effect.Effect - const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion).pipe( + const runAfterOverflowCompaction: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) if (defect.transition._tag === "ContinueAfterOverflowCompaction") return yield* Effect.die("Post-compaction provider attempt cannot recover another overflow") yield* Effect.yieldNow - return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, defect.transition.promotion, step) }), ), ) }) - const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion) { - return yield* runTurnAttempt(sessionID, promotion, compaction.compactAfterOverflow).pipe( + const runTurn: RunTurn = Effect.fnUntraced(function* (sessionID, promotion, step) { + return yield* runTurnAttempt(sessionID, promotion, step, compaction.compactAfterOverflow).pipe( Effect.catchDefect( Effect.fnUntraced(function* (defect) { if (!(defect instanceof TurnTransitionError)) return yield* Effect.die(defect) yield* Effect.yieldNow if (defect.transition._tag === "ContinueAfterOverflowCompaction") - return yield* runAfterOverflowCompaction(sessionID, undefined) - return yield* runTurn(sessionID, defect.transition.promotion) + return yield* runAfterOverflowCompaction(sessionID, undefined, step) + return yield* runTurn(sessionID, defect.transition.promotion, step) }), ), ) @@ -382,14 +389,11 @@ export const layer = Layer.effect( let openActivity = input.force === true || hasSteer || hasQueue while (openActivity) { let needsContinuation = true - for (let step = 0; step < MAX_STEPS; step++) { - needsContinuation = yield* runTurn(input.sessionID, promotion) + for (let step = 1; needsContinuation; step++) { + needsContinuation = yield* runTurn(input.sessionID, promotion, step) promotion = "steer" if (!needsContinuation) needsContinuation = yield* SessionInput.hasPending(db, input.sessionID, "steer") - if (!needsContinuation) break } - if (needsContinuation) - return yield* new StepLimitExceededError({ sessionID: input.sessionID, limit: MAX_STEPS }) openActivity = yield* SessionInput.hasPending(db, input.sessionID, "queue") promotion = openActivity ? "queue" : undefined } diff --git a/packages/opencode/src/session/prompt/max-steps.txt b/packages/core/src/session/runner/max-steps.ts similarity index 90% rename from packages/opencode/src/session/prompt/max-steps.txt rename to packages/core/src/session/runner/max-steps.ts index 3aefa73779..040584ab17 100644 --- a/packages/opencode/src/session/prompt/max-steps.txt +++ b/packages/core/src/session/runner/max-steps.ts @@ -1,4 +1,4 @@ -CRITICAL - MAXIMUM STEPS REACHED +export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only. @@ -13,4 +13,4 @@ Response must include: - List of any remaining tasks that were not completed - Recommendations for what should be done next -Any attempt to use tools is a critical violation. Respond with text ONLY. \ No newline at end of file +Any attempt to use tools is a critical violation. Respond with text ONLY.` diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 27bd15ec8d..d067a7c6b9 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -8,6 +8,9 @@ import { Auth, type AnyRoute } from "@opencode-ai/llm/route" import { Context, Effect, Layer, Option, Schema } from "effect" import { produce } from "immer" import { Catalog } from "../../catalog" +import { Credential } from "../../credential" +import { Integration } from "../../integration" +import { IntegrationConnection } from "../../integration/connection" import { ModelV2 } from "../../model" import { ModelRequest } from "../../model-request" import { PluginBoot } from "../../plugin/boot" @@ -45,10 +48,12 @@ export class Service extends Context.Service()("@opencode/v2 /** Test or embedding seam for supplying a model resolver directly. */ export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve })) -const apiKey = (model: ModelV2.Info, provider?: ProviderV2.Info) => { +const apiKey = (model: ModelV2.Info, connection?: IntegrationConnection.Info, credential?: Credential.Stored) => { + if (credential?.value.type === "key") return Auth.value(credential.value.key) + if (credential?.value.type === "oauth") return Auth.value(credential.value.access) const value = model.request.body.apiKey ?? model.api.settings?.apiKey if (typeof value === "string") return Auth.value(value) - return provider?.enabled !== false && provider?.enabled.via === "env" ? Auth.config(provider.enabled.name) : undefined + return connection?.type === "env" ? Auth.config(connection.name) : undefined } const withDefaults = (model: ModelV2.Info, route: AnyRoute) => { @@ -83,41 +88,54 @@ const apiName = (model: ModelV2.Info) => export const fromCatalogModel = ( model: ModelV2.Info, - provider?: ProviderV2.Info, + connection?: IntegrationConnection.Info, + credential?: Credential.Stored, ): Effect.Effect => { - const key = apiKey(model, provider) - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai") { + const resolved = + credential?.value.metadata === undefined + ? model + : produce(model, (draft) => { + Object.assign(draft.request.body, credential.value.metadata) + // kilocode_change start - Kilo Gateway consumes the migrated OAuth account as its organization route + if (credential.value.type === "oauth" && credential.value.metadata?.accountID) { + draft.request.body.kilocodeOrganizationId = credential.value.metadata.accountID + delete draft.request.body.accountID + } + // kilocode_change end + }) + const key = apiKey(resolved, connection, credential) + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai") { return Effect.succeed( - withDefaults(model, OpenAIResponses.route) + withDefaults(resolved, OpenAIResponses.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/anthropic") { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/anthropic") { return Effect.succeed( - withDefaults(model, AnthropicMessages.route) + withDefaults(resolved, AnthropicMessages.route) .with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } - if (model.api.type === "aisdk" && model.api.package === "@ai-sdk/openai-compatible" && model.api.url) { + if (resolved.api.type === "aisdk" && resolved.api.package === "@ai-sdk/openai-compatible" && resolved.api.url) { return Effect.succeed( - withDefaults(model, OpenAICompatibleChat.route) + withDefaults(resolved, OpenAICompatibleChat.route) .with({ auth: key === undefined ? Auth.none : Auth.bearer(key) }) - .model({ id: model.api.id }), + .model({ id: resolved.api.id }), ) } return Effect.fail( new UnsupportedApiError({ - providerID: model.providerID, - modelID: model.id, - api: apiName(model), + providerID: resolved.providerID, + modelID: resolved.id, + api: apiName(resolved), }), ) } -export const resolve = (session: SessionSchema.Info, model: ModelV2.Info, provider?: ProviderV2.Info) => - fromCatalogModel(withVariant(model, session.model?.variant), provider) +export const resolve = (session: SessionSchema.Info, model: ModelV2.Info) => + fromCatalogModel(withVariant(model, session.model?.variant)) export const supported = (model: ModelV2.Info) => model.api.type === "aisdk" && @@ -130,6 +148,8 @@ export const locationLayer = Layer.effect( Service, Effect.gen(function* () { const catalog = yield* Catalog.Service + const credentials = yield* Credential.Service + const integrations = yield* Integration.Service const boot = yield* PluginBoot.Service return Service.of({ resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { @@ -140,7 +160,12 @@ export const locationLayer = Layer.effect( : (Option.getOrUndefined((yield* catalog.model.default()).pipe(Option.filter(supported))) ?? (yield* catalog.model.available()).find(supported)) if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id }) - return yield* resolve(session, selected, yield* catalog.provider.get(selected.providerID)) + const connection = yield* integrations.connection.forIntegration(Integration.ID.make(selected.providerID)) + return yield* fromCatalogModel( + withVariant(selected, session.model?.variant), + connection, + connection?.type === "credential" ? yield* credentials.get(connection.id) : undefined, + ) }), }) }), diff --git a/packages/opencode/src/shell/shell.ts b/packages/core/src/shell.ts similarity index 80% rename from packages/opencode/src/shell/shell.ts rename to packages/core/src/shell.ts index f7e8327163..f92906aebe 100644 --- a/packages/opencode/src/shell/shell.ts +++ b/packages/core/src/shell.ts @@ -1,11 +1,14 @@ -import { Flag } from "@opencode-ai/core/flag/flag" -import * as PowerShell from "@/kilocode/shell/shell" // kilocode_change - PowerShell args -import { lazy } from "@/util/lazy" -import { Filesystem } from "@/util/filesystem" -import { which } from "@opencode-ai/core/util/which" +export * as Shell from "./shell" + import path from "path" import { spawn, type ChildProcess } from "child_process" +import { readFile } from "fs/promises" +import { statSync } from "fs" import { setTimeout as sleep } from "node:timers/promises" +import { Flag } from "./flag/flag" +import { FSUtil } from "./fs-util" +import { which } from "./util/which" +import { PowerShell } from "./kilocode/powershell" // kilocode_change const SIGKILL_TIMEOUT_MS = 200 const META: Record = { @@ -48,7 +51,7 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole if (!opts?.exited?.()) { process.kill(-pid, "SIGKILL") } - } catch (_e) { + } catch { proc.kill("SIGTERM") await sleep(SIGKILL_TIMEOUT_MS) if (!opts?.exited?.()) { @@ -57,9 +60,13 @@ export async function killTree(proc: ChildProcess, opts?: { exited?: () => boole } } +function stat(file: string) { + return statSync(file, { throwIfNoEntry: false }) ?? undefined +} + function full(file: string) { if (process.platform !== "win32") return file - const shell = Filesystem.windowsPath(file) + const shell = FSUtil.windowsPath(file) if (path.win32.dirname(shell) !== ".") { if (shell.startsWith("/") && name(shell) === "bash") return gitbash() || shell return shell @@ -77,13 +84,13 @@ function ok(file: string) { } function rooted(file: string) { - return path.isAbsolute(Filesystem.windowsPath(file)) + return path.isAbsolute(FSUtil.windowsPath(file)) } function resolve(file: string) { const shell = full(file) if (rooted(shell)) { - if (Filesystem.stat(shell)?.isFile()) return shell + if (stat(shell)?.isFile()) return shell return } return which(shell) ?? undefined @@ -100,7 +107,7 @@ function win() { } async function unix() { - const text = await Filesystem.readText("/etc/shells").catch(() => "") + const text = await readFile("/etc/shells", "utf8").catch(() => "") if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#")))) return ["/bin/bash", "/bin/zsh", "/bin/sh"] } @@ -110,7 +117,7 @@ function select(file: string | undefined, opts?: { acceptable?: boolean }) { const shell = resolve(file) if (shell) return shell } - if (process.platform === "win32") return win()[0]! + if (process.platform === "win32") return win()[0] return fallback() } @@ -120,7 +127,7 @@ export function gitbash() { const git = which("git") if (!git) return const file = path.join(git, "..", "..", "bin", "bash.exe") - if (Filesystem.stat(file)?.size) return file + if (stat(file)?.size) return file } function fallback() { @@ -131,7 +138,7 @@ function fallback() { } export function name(file: string) { - if (process.platform === "win32") return path.win32.parse(Filesystem.windowsPath(file)).name.toLowerCase() + if (process.platform === "win32") return path.win32.parse(FSUtil.windowsPath(file)).name.toLowerCase() return path.basename(file).toLowerCase() } @@ -189,28 +196,32 @@ export function args(file: string, command: string, cwd: string) { ] } if (n === "cmd") return ["/c", command] - if (ps(file)) return PowerShell.args(command) // kilocode_change - PowerShell args + if (ps(file)) return PowerShell.args(command) // kilocode_change - preserve UTF-8 and script prologues return ["-c", command] } -const defaultPreferred = lazy(() => select(process.env.SHELL)) -const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true })) +let defaultPreferred: string | undefined +let defaultAcceptable: string | undefined export function preferred(configShell?: string) { if (configShell) return select(configShell) - return defaultPreferred() + defaultPreferred ??= select(process.env.SHELL) + return defaultPreferred +} +preferred.reset = () => { + defaultPreferred = undefined } -preferred.reset = () => defaultPreferred.reset() export function acceptable(configShell?: string) { if (configShell) return select(configShell, { acceptable: true }) - return defaultAcceptable() + defaultAcceptable ??= select(process.env.SHELL, { acceptable: true }) + return defaultAcceptable +} +acceptable.reset = () => { + defaultAcceptable = undefined } -acceptable.reset = () => defaultAcceptable.reset() export async function list(): Promise { const shells = process.platform === "win32" ? win() : await unix() return shells.filter((s) => resolve(s)).map(info) } - -export * as Shell from "./shell" diff --git a/packages/core/sst-env.d.ts b/packages/core/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/core/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/core/test/catalog.test.ts b/packages/core/test/catalog.test.ts index 9b1cf89637..77a18e79ae 100644 --- a/packages/core/test/catalog.test.ts +++ b/packages/core/test/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect } from "bun:test" -import { DateTime, Effect, Layer, Option } from "effect" +import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Integration } from "@opencode-ai/core/integration" import { Credential } from "@opencode-ai/core/credential" @@ -25,47 +25,29 @@ const it = testEffect( Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.succeed([]), + list: () => Effect.succeed([]), }), ), ), ) describe("CatalogV2", () => { - it.effect("projects Kilo organization routing from OAuth credentials", () => { - const integrationID = Integration.ID.make("kilocode") - const credential = new Credential.Stored({ - id: Credential.ID.create(), - integrationID, - label: "Organization", - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - access: "access", - refresh: "refresh", - expires: 1, - metadata: { accountID: "organization" }, - }), - }) - const layer = Catalog.locationLayer.pipe( - Layer.fresh, - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge(locationLayer), - Layer.provideMerge( - Layer.mock(Credential.Service)({ all: () => Effect.succeed([credential]) }), - ), - ) - - return Effect.gen(function* () { + it.effect("publishes an updated event after catalog changes", () => + Effect.gen(function* () { const catalog = yield* Catalog.Service - const transform = yield* catalog.transform() - yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("kilocode"), () => {})) - expect(yield* catalog.provider.get(ProviderV2.ID.make("kilocode"))).toMatchObject({ - request: { body: { apiKey: "access", kilocodeOrganizationId: "organization" } }, - }) - }).pipe(Effect.provide(layer)) - }) + const events = yield* EventV2.Service + const updated = yield* events + .subscribe(Catalog.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow - it.effect("projects active credentials without rebuilding catalog state", () => { + yield* (yield* catalog.transform())((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) + + expect((yield* Fiber.join(updated)).length).toBe(1) + }), + ) + + it.effect("derives availability from active credentials without changing provider state", () => { const integrationID = Integration.ID.make("test") const first = { id: Credential.ID.create(), @@ -87,6 +69,7 @@ describe("CatalogV2", () => { Layer.provideMerge( Layer.mock(Credential.Service)({ all: () => Effect.sync(() => [active]), + list: () => Effect.sync(() => [active]), }), ), ) @@ -96,18 +79,44 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((editor) => editor.provider.update(ProviderV2.ID.make("test"), () => {})) - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: first.id }, - request: { body: { apiKey: "first", tenant: "one" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) active = second - expect(yield* catalog.provider.get(ProviderV2.ID.make("test"))).toMatchObject({ - enabled: { via: "credential", credentialID: second.id }, - request: { body: { apiKey: "second", tenant: "two" } }, - }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toEqual([ProviderV2.ID.make("test")]) + expect((yield* catalog.provider.get(ProviderV2.ID.make("test"))).request.body).toEqual({}) }).pipe(Effect.provide(layer)) }) + it.effect("projects environment connections without a catalog plugin", () => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.CATALOG_TEST_API_KEY + process.env.CATALOG_TEST_API_KEY = "secret" + return previous + }), + () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const providerID = ProviderV2.ID.make("test") + yield* integrations.update((editor) => + editor.method.update({ + integrationID: Integration.ID.make(providerID), + method: { type: "env", names: ["CATALOG_TEST_API_KEY"] }, + }), + ) + yield* (yield* catalog.transform())((editor) => editor.provider.update(providerID, () => {})) + + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain(providerID) + }), + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.CATALOG_TEST_API_KEY + else process.env.CATALOG_TEST_API_KEY = previous + }), + ), + ) + it.effect("normalizes provider baseURL into api url", () => Effect.gen(function* () { const catalog = yield* Catalog.Service @@ -326,9 +335,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() yield* transform((catalog) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -350,9 +357,7 @@ describe("CatalogV2", () => { const transform = yield* catalog.transform() const models = (catalog: Catalog.Editor) => { - catalog.provider.update(providerID, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(providerID, () => {}) catalog.model.update(providerID, old, (model) => { model.time.released = DateTime.makeUnsafe(1000) }) @@ -383,12 +388,10 @@ describe("CatalogV2", () => { yield* transform((catalog) => { catalog.provider.update(disabledProvider, (provider) => { - provider.enabled = false + provider.disabled = true }) catalog.model.update(disabledProvider, disabledModel, () => {}) - catalog.provider.update(enabledProvider, (provider) => { - provider.enabled = { via: "custom", data: {} } - }) + catalog.provider.update(enabledProvider, () => {}) catalog.model.update(enabledProvider, fallbackModel, () => {}) catalog.model.default.set(disabledProvider, disabledModel) }) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index 6a2510bab8..a2ecc9954b 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -3,10 +3,11 @@ import { Effect, Option, Schema } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Config } from "@opencode-ai/core/config" import { ConfigProviderPlugin } from "@opencode-ai/core/config/plugin/provider" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderV2 } from "@opencode-ai/core/provider" -import { it } from "../plugin/provider-helper" +import { it, withEnv } from "../plugin/provider-helper" function request(headers: Record, variant?: string) { return { @@ -21,6 +22,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("partitions existing model variant bodies without changing config shape", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -59,6 +61,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), ), }) @@ -80,6 +83,7 @@ describe("ConfigProviderPlugin.Plugin", () => { it.effect("uses the effective provider package across layered config", () => Effect.gen(function* () { const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service const plugin = yield* PluginV2.Service const providerID = ProviderV2.ID.opencode const modelID = ModelV2.ID.make("alpha-gpt-next") @@ -118,6 +122,7 @@ describe("ConfigProviderPlugin.Plugin", () => { effect: ConfigProviderPlugin.Plugin.effect.pipe( Effect.provideService(Config.Service, config), Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), ), }) @@ -131,118 +136,126 @@ describe("ConfigProviderPlugin.Plugin", () => { ) it.effect("loads configured providers and applies later model overrides", () => - Effect.gen(function* () { - const catalog = yield* Catalog.Service - const plugin = yield* PluginV2.Service - const providerID = ProviderV2.ID.make("custom") - const modelID = ModelV2.ID.make("chat") - const config = Config.Service.of({ - entries: () => - Effect.succeed([ - new Config.Document({ - type: "document", - info: decode({ - model: "custom/first", - providers: { - custom: { - name: "Configured", - env: ["CUSTOM_API_KEY"], - api: { type: "native", settings: {} }, - request: request({ first: "first", shared: "first" }), - models: { - chat: { - name: "First", - capabilities: { tools: true, input: ["text"], output: ["text"] }, - disabled: true, - limit: { context: 100, output: 50 }, - cost: { input: 1, output: 2 }, - request: request({ first: "first", shared: "first" }, "retained"), - variants: [ - { - id: "fast", - headers: { first: "first", shared: "first" }, - }, - ], + withEnv({ CUSTOM_API_KEY: "secret" }, () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const integrations = yield* Integration.Service + const plugin = yield* PluginV2.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + model: "custom/first", + providers: { + custom: { + name: "Configured", + env: ["CUSTOM_API_KEY"], + api: { type: "native", settings: {} }, + request: request({ first: "first", shared: "first" }), + models: { + chat: { + name: "First", + capabilities: { tools: true, input: ["text"], output: ["text"] }, + disabled: true, + limit: { context: 100, output: 50 }, + cost: { input: 1, output: 2 }, + request: request({ first: "first", shared: "first" }, "retained"), + variants: [ + { + id: "fast", + headers: { first: "first", shared: "first" }, + }, + ], + }, }, }, }, - }, + }), }), - }), - new Config.Document({ - type: "document", - info: decode({ - model: "custom/default", - providers: { - custom: { - api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, - request: request({ last: "last", shared: "last" }), - models: { - default: { - name: "Default", - }, - chat: { - api: { id: "api-chat" }, - name: "Last", - limit: { output: 75 }, - request: request({ last: "last", shared: "last" }), - variants: [ - { - id: "fast", - headers: { last: "last", shared: "last" }, - }, - { - id: "slow", - headers: { slow: "slow" }, - }, - ], + new Config.Document({ + type: "document", + info: decode({ + model: "custom/default", + providers: { + custom: { + api: { type: "aisdk", package: "custom-sdk", url: "https://example.test" }, + request: request({ last: "last", shared: "last" }), + models: { + default: { + name: "Default", + }, + chat: { + api: { id: "api-chat" }, + name: "Last", + limit: { output: 75 }, + request: request({ last: "last", shared: "last" }), + variants: [ + { + id: "fast", + headers: { last: "last", shared: "last" }, + }, + { + id: "slow", + headers: { slow: "slow" }, + }, + ], + }, }, }, }, - }, + }), }), - }), - new Config.Document({ - type: "document", - info: decode({ - providers: { - custom: { name: "Renamed" }, - }, + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { name: "Renamed" }, + }, + }), }), - }), - ]), - }) + ]), + }) - yield* plugin.add({ - ...ConfigProviderPlugin.Plugin, - effect: ConfigProviderPlugin.Plugin.effect.pipe( - Effect.provideService(Config.Service, config), - Effect.provideService(Catalog.Service, catalog), - ), - }) + yield* plugin.add({ + ...ConfigProviderPlugin.Plugin, + effect: ConfigProviderPlugin.Plugin.effect.pipe( + Effect.provideService(Config.Service, config), + Effect.provideService(Catalog.Service, catalog), + Effect.provideService(Integration.Service, integrations), + ), + }) - const provider = yield* catalog.provider.get(providerID) - const model = yield* catalog.model.get(providerID, modelID) - expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) - expect(provider.name).toBe("Renamed") - expect(provider.env).toEqual(["CUSTOM_API_KEY"]) - expect(provider.enabled).toEqual({ via: "custom", data: {} }) - expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) - expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) - expect(model.name).toBe("Last") - expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) - expect(model.enabled).toBe(false) - expect(model.limit).toEqual({ context: 100, output: 75 }) - expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) - expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.request.variant).toBe("retained") - expect(model.variants.map((variant) => variant.id)).toEqual([ - ModelV2.VariantID.make("fast"), - ModelV2.VariantID.make("slow"), - ]) - expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) - expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) - }), + const provider = yield* catalog.provider.get(providerID) + const model = yield* catalog.model.get(providerID, modelID) + expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toBe(ModelV2.ID.make("default")) + expect(provider.name).toBe("Renamed") + expect((yield* integrations.get(Integration.ID.make("custom")))?.methods).toContainEqual({ + type: "env", + names: ["CUSTOM_API_KEY"], + }) + expect((yield* integrations.get(Integration.ID.make("custom")))?.name).toBe("Renamed") + expect(provider.disabled).toBeUndefined() + expect(provider.api).toEqual({ type: "aisdk", package: "custom-sdk", url: "https://example.test" }) + expect(provider.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.api.id).toBe(ModelV2.ID.make("api-chat")) + expect(model.name).toBe("Last") + expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] }) + expect(model.enabled).toBe(false) + expect(model.limit).toEqual({ context: 100, output: 75 }) + expect(model.cost).toEqual([{ input: 1, output: 2, cache: { read: 0, write: 0 }, tier: undefined }]) + expect(model.request.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.request.variant).toBe("retained") + expect(model.variants.map((variant) => variant.id)).toEqual([ + ModelV2.VariantID.make("fast"), + ModelV2.VariantID.make("slow"), + ]) + expect(model.variants[0]?.headers).toEqual({ first: "first", shared: "last", last: "last" }) + expect(model.variants[1]?.headers).toEqual({ slow: "slow" }) + }), + ), ) }) diff --git a/packages/core/test/integration.test.ts b/packages/core/test/integration.test.ts index fa05e23d95..ca4362c605 100644 --- a/packages/core/test/integration.test.ts +++ b/packages/core/test/integration.test.ts @@ -1,8 +1,7 @@ import { describe, expect } from "bun:test" -import { Duration, Effect, Exit, Layer, Scope } from "effect" +import { Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect" import * as TestClock from "effect/testing/TestClock" import { Integration } from "@opencode-ai/core/integration" -import { IntegrationConnection } from "@opencode-ai/core/integration/connection" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" import { it } from "./lib/effect" @@ -25,7 +24,7 @@ function connectionLayer( }>, ) { return Integration.locationLayer.pipe( - Layer.provide(EventV2.defaultLayer), + Layer.provideMerge(EventV2.defaultLayer), Layer.provide( Layer.mock(Credential.Service)({ create: (input) => @@ -103,7 +102,7 @@ describe("Integration", () => { .update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize, }), ) @@ -117,7 +116,7 @@ describe("Integration", () => { ]) editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT Override" }), + method: { id: methodID, type: "oauth", label: "ChatGPT Override" }, authorize, }) }) @@ -140,15 +139,20 @@ describe("Integration", () => { }> = [] return Effect.gen(function* () { const integrations = yield* Integration.Service + const events = yield* EventV2.Service const integrationID = Integration.ID.make("openai") yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.KeyMethod({ type: "key", label: "API key" }), + method: { type: "key", label: "API key" }, }), ) + const updated = yield* events + .subscribe(Integration.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped) + yield* Effect.yieldNow - yield* integrations.connect.key({ + yield* integrations.connection.key({ integrationID, key: "secret", label: "Work", @@ -161,6 +165,7 @@ describe("Integration", () => { value: new Credential.Key({ type: "key", key: "secret" }), }, ]) + expect((yield* Fiber.join(updated)).length).toBe(1) }).pipe(Effect.provide(connectionLayer(created))) }) @@ -177,7 +182,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -198,7 +203,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {}, @@ -236,7 +241,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -249,7 +254,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( Integration.CodeRequiredError, ) @@ -273,7 +278,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.succeed({ mode: "auto" as const, @@ -286,7 +291,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ status: "complete", @@ -310,7 +315,7 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -323,7 +328,7 @@ describe("Integration", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10))) yield* TestClock.adjust(Duration.minutes(10)) yield* Effect.yieldNow @@ -373,23 +378,28 @@ describe("Integration", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.EnvMethod({ + method: { type: "env", names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"], - }), + }, }), ) // Stored credentials and detected env vars appear as connections. expect((yield* integrations.get(integrationID))?.connections).toEqual([ - new IntegrationConnection.CredentialInfo({ type: "credential", id: rows[0]!.id, label: "Work" }), - new IntegrationConnection.CredentialInfo({ + { type: "credential", id: rows[0]!.id, label: "Work" }, + { type: "credential", id: rows[1]!.id, label: "Personal", - }), - new IntegrationConnection.EnvInfo({ type: "env", name: "INTEGRATION_TEST_ACME_KEY" }), + }, + { type: "env", name: "INTEGRATION_TEST_ACME_KEY" }, ]) + expect(yield* integrations.connection.forIntegration(integrationID)).toEqual({ + type: "credential", + id: rows[1]!.id, + label: "Personal", + }) }).pipe(Effect.provide(projectionLayer)), (previous) => Effect.sync(() => { diff --git a/packages/core/test/kilocode/integration-settlement.test.ts b/packages/core/test/kilocode/integration-settlement.test.ts index 0eae2b2aad..9085d2526f 100644 --- a/packages/core/test/kilocode/integration-settlement.test.ts +++ b/packages/core/test/kilocode/integration-settlement.test.ts @@ -65,7 +65,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "Browser" }), + method: { id: methodID, type: "oauth", label: "Browser" }, authorize: () => Effect.succeed({ mode: "auto" as const, @@ -78,7 +78,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) yield* Effect.yieldNow expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({ status: "failed", @@ -104,7 +104,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -118,10 +118,10 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) - const exit = yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" }).pipe( - Effect.exit, - ) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) + const exit = yield* integrations.attempt + .complete({ attemptID: attempt.attemptID, code: "1234" }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) expect(Cause.pretty(exit.cause)).toContain("database unavailable") expect(yield* integrations.attempt.status(attempt.attemptID)).toMatchObject({ @@ -165,7 +165,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -179,7 +179,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.forkScoped) @@ -212,7 +212,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.succeed({ mode: "code" as const, @@ -229,7 +229,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.forkScoped) @@ -253,7 +253,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (state.closed = true))).pipe( Effect.as({ @@ -266,7 +266,7 @@ describe("Integration settlement guards", () => { }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit, Effect.forkScoped) @@ -301,7 +301,7 @@ describe("Integration settlement guards", () => { yield* integrations.update((editor) => editor.method.update({ integrationID, - method: new Integration.OAuthMethod({ id: methodID, type: "oauth", label: "ChatGPT" }), + method: { id: methodID, type: "oauth", label: "ChatGPT" }, authorize: () => Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe( Effect.as({ @@ -310,14 +310,20 @@ describe("Integration settlement guards", () => { instructions: "Paste the code", callback: () => Effect.succeed( - new Credential.OAuth({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }), + new Credential.OAuth({ + type: "oauth", + methodID, + access: "access", + refresh: "refresh", + expires: 1, + }), ), }), ), }), ) - const attempt = yield* integrations.connect.oauth({ integrationID, methodID, inputs: {} }) + const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} }) const fiber = yield* integrations.attempt .complete({ attemptID: attempt.attemptID, code: "1234" }) .pipe(Effect.exit, Effect.forkScoped) diff --git a/packages/core/test/kilocode/provider-isolation.test.ts b/packages/core/test/kilocode/provider-isolation.test.ts index 57d010755d..c2d2d498bc 100644 --- a/packages/core/test/kilocode/provider-isolation.test.ts +++ b/packages/core/test/kilocode/provider-isolation.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" @@ -16,7 +17,12 @@ describe("provider attribution isolation", () => { Effect.gen(function* () { const plugins = yield* PluginV2.Service const catalog = yield* Catalog.Service - for (const plugin of [LLMGatewayPlugin, NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { + const integrations = yield* Integration.Service + yield* plugins.add({ + ...LLMGatewayPlugin, + effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + }) + for (const plugin of [NvidiaPlugin, OpenRouterPlugin, VercelPlugin, ZenmuxPlugin]) { yield* plugins.add(plugin) } @@ -24,7 +30,6 @@ describe("provider attribution isolation", () => { yield* transform((catalog) => { const items = [ provider("custom-llmgateway", { - enabled: { via: "env", name: "CUSTOM_LLMGATEWAY_API_KEY" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, }), provider("custom-nvidia", { @@ -47,7 +52,6 @@ describe("provider attribution isolation", () => { for (const item of items) { catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled draft.api = item.api draft.request.headers.Existing = "value" }) diff --git a/packages/core/test/kilocode/session-runner-model.test.ts b/packages/core/test/kilocode/session-runner-model.test.ts new file mode 100644 index 0000000000..5f28d4609f --- /dev/null +++ b/packages/core/test/kilocode/session-runner-model.test.ts @@ -0,0 +1,56 @@ +import { describe, expect } from "bun:test" +import { DateTime, Effect } from "effect" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { it } from "../lib/effect" + +describe("SessionRunnerModel Kilo credentials", () => { + it.effect("maps OAuth account IDs to Kilo organization routing", () => + Effect.gen(function* () { + const model = new ModelV2.Info({ + id: ModelV2.ID.make("test-model"), + providerID: ProviderV2.ID.make("kilo"), + name: "Test model", + api: { + id: ModelV2.ID.make("api-test-model"), + type: "aisdk", + package: "@ai-sdk/openai-compatible", + url: "https://api.kilo.ai/openrouter", + }, + capabilities: { tools: true, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {}, generation: {}, options: {} }, + variants: [], + time: { released: DateTime.makeUnsafe(0) }, + cost: [], + status: "active", + enabled: true, + limit: { context: 100, output: 20 }, + }) + const credential = new Credential.Stored({ + id: Credential.ID.create(), + integrationID: Integration.ID.make("kilo"), + label: "Work", + value: new Credential.OAuth({ + type: "oauth", + methodID: Integration.MethodID.make("oauth"), + refresh: "refresh", + access: "access", + expires: 1, + metadata: { accountID: "org-enterprise" }, + }), + }) + + const resolved = yield* SessionRunnerModel.fromCatalogModel( + model, + { type: "credential", id: credential.id, label: credential.label }, + credential, + ) + + expect(resolved.route.defaults.http?.body).toMatchObject({ kilocodeOrganizationId: "org-enterprise" }) + expect(resolved.route.defaults.http?.body).not.toHaveProperty("accountID") + }), + ) +}) diff --git a/packages/core/test/plugin/models-dev.test.ts b/packages/core/test/plugin/models-dev.test.ts index 8cdfc066cd..f4a0e16bdd 100644 --- a/packages/core/test/plugin/models-dev.test.ts +++ b/packages/core/test/plugin/models-dev.test.ts @@ -28,8 +28,10 @@ const connections = Credential.layer.pipe( Layer.provide(Database.layerFromPath(":memory:").pipe(Layer.fresh)), Layer.provide(events), ) -const catalog = Catalog.layer.pipe(Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections))) const integrations = Integration.locationLayer.pipe(Layer.provide(events), Layer.provide(connections)) +const catalog = Catalog.layer.pipe( + Layer.provide(Layer.mergeAll(events, locationLayer, plugins, policy, connections, integrations)), +) const layer = Layer.mergeAll( catalog.pipe(Layer.provide(connections)), integrations, @@ -61,11 +63,11 @@ describe("ModelsDevPlugin", () => { id: Integration.ID.make("acme"), name: "Acme", methods: [ - new Integration.KeyMethod({ type: "key" }), - new Integration.EnvMethod({ + { type: "key" }, + { type: "env", names: ["ACME_API_KEY"], - }), + }, ], connections: [], }), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index c4bdd806c9..91ab854d5c 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -1,35 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper" describe("AzurePlugin", () => { it.effect("resolves resourceName from env", () => @@ -73,35 +48,7 @@ describe("AzurePlugin", () => { ), ) - itWithAccount.effect("prefers account resourceName over env", () => - withEnv( - { - AZURE_RESOURCE_NAME: "from-env", - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("azure"), - value: new Credential.Key({ - type: "key", - key: "key", - metadata: { resourceName: "from-account" }, - }), - }) - yield* plugin.add(AzurePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - catalog.provider.update(ProviderV2.ID.azure, (item) => { - item.api = { type: "aisdk", package: "@ai-sdk/azure" } - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.azure)).request.body.resourceName).toBe("from-account") - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("falls back to env when configured resourceName is blank", () => withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () => diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index 208ab8710d..39323f77e1 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -1,36 +1,11 @@ import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" +import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Location } from "@opencode-ai/core/location" -import { EventV2 } from "@opencode-ai/core/event" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai" import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper" - -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("test") }))), - ), - Layer.provideMerge(npmLayer), - ), -) +import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper" function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") { return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel( @@ -126,41 +101,7 @@ describe("CloudflareWorkersAIPlugin", () => { ), ) - itWithAccount.effect("falls back to account metadata when account env is absent", () => - withEnv( - { - CLOUDFLARE_ACCOUNT_ID: undefined, - CLOUDFLARE_API_KEY: undefined, - }, - () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("cloudflare-workers-ai"), - value: new Credential.Key({ - type: "key", - key: "account-key", - metadata: { accountId: "account-acct" }, - }), - }) - yield* plugin.add(CloudflareWorkersAIPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => - catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => { - provider.api = { type: "aisdk", package: "test-provider" } - }), - ) - expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).request.body).toMatchObject( - { - apiKey: "account-key", - accountId: "account-acct", - }, - ) - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("uses env account ID over configured account ID", () => withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () => diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index dab52a1f7f..3e036ad712 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -1,26 +1,10 @@ import { describe, expect, mock } from "bun:test" -import { Effect, Layer } from "effect" -import { Credential } from "@opencode-ai/core/credential" -import { Integration } from "@opencode-ai/core/integration" -import { Database } from "@opencode-ai/core/database/database" -import { Catalog } from "@opencode-ai/core/catalog" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" +import { Effect } from "effect" import { PluginV2 } from "@opencode-ai/core/plugin" import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" -import { it, model, npmLayer, withEnv } from "./provider-helper" +import { it, model, withEnv } from "./provider-helper" const gitlabSDKOptions: Record[] = [] -const database = Database.layerFromPath(":memory:").pipe(Layer.fresh) -const preferences = Credential.layer.pipe(Layer.provide(database)) -const accounts = Layer.merge( - Credential.layer.pipe(Layer.provide(database), Layer.provide(preferences), Layer.provide(EventV2.defaultLayer)), - preferences, -) void mock.module("gitlab-ai-provider", () => ({ VERSION: "test-version", @@ -35,17 +19,6 @@ void mock.module("gitlab-ai-provider", () => ({ isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact", })) -const itWithAccount = testEffect( - Catalog.locationLayer.pipe( - Layer.provideMerge(accounts), - Layer.provideMerge(EventV2.defaultLayer), - Layer.provideMerge( - Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/") }))), - ), - Layer.provideMerge(npmLayer), - ), -) - describe("GitLabPlugin", () => { it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () => withEnv( @@ -163,77 +136,7 @@ describe("GitLabPlugin", () => { }), ) - itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () => - withEnv( - { - GITLAB_TOKEN: "env-token", - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.Key({ type: "key", key: "account-token" }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-token") - }), - ), - ) - - itWithAccount.effect("uses active account OAuth access token when no API token exists", () => - withEnv( - { - GITLAB_TOKEN: undefined, - }, - () => - Effect.gen(function* () { - gitlabSDKOptions.length = 0 - const plugin = yield* PluginV2.Service - const credentials = yield* Credential.Service - const catalog = yield* Catalog.Service - yield* credentials.create({ - integrationID: Integration.ID.make("gitlab"), - value: new Credential.OAuth({ - type: "oauth", - methodID: Integration.MethodID.make("oauth"), - refresh: "refresh-token", - access: "account-oauth-token", - expires: 9999999999999, - }), - }) - yield* plugin.add(GitLabPlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {})) - const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab")) - yield* plugin.trigger( - "aisdk.sdk", - { - model: model("gitlab", "claude"), - package: "gitlab-ai-provider", - options: provider.request.body, - }, - {}, - ) - expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token") - }), - ), - ) + // kilocode_change - remove stale account projection coverage, matching the v1.17.10 upstream follow-up it.effect("uses workflowChat for duo workflow models and preserves selectedModelRef", () => Effect.gen(function* () { diff --git a/packages/core/test/plugin/provider-helper.ts b/packages/core/test/plugin/provider-helper.ts index f9334480d1..c15928435b 100644 --- a/packages/core/test/plugin/provider-helper.ts +++ b/packages/core/test/plugin/provider-helper.ts @@ -53,6 +53,7 @@ const integrations = Integration.locationLayer.pipe( Layer.provide( Layer.mock(Credential.Service)({ create: () => Effect.die("unexpected credential creation"), + all: () => Effect.succeed([]), list: () => Effect.succeed([]), }), ), diff --git a/packages/core/test/plugin/provider-kilo.test.ts b/packages/core/test/plugin/provider-kilo.test.ts index a14c78821a..4bc43c156f 100644 --- a/packages/core/test/plugin/provider-kilo.test.ts +++ b/packages/core/test/plugin/provider-kilo.test.ts @@ -1,7 +1,6 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" -import { Credential } from "@opencode-ai/core/credential" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo" @@ -151,20 +150,18 @@ describe("KiloPlugin", () => { const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("kilo", { - enabled: { via: "credential", credentialID: Credential.ID.make("cred_kilo") }, request: { headers: {}, body: { apiKey: "authenticated-token", kilocodeOrganizationId: "authenticated-org" }, }, }) catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled draft.request = item.request }) }) const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.enabled).toEqual({ via: "credential", credentialID: Credential.ID.make("cred_kilo") }) + expect(result.request.body.apiKey).toBe("authenticated-token") expect(result.request.body.kilocodeToken).toBe("authenticated-token") expect(result.request.body.kilocodeOrganizationId).toBe("environment-org") }), @@ -181,7 +178,10 @@ describe("KiloPlugin", () => { yield* transform((catalog) => catalog.provider.update(ProviderV2.ID.make("kilo"), () => {})) const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo")) - expect(result.enabled).toEqual({ via: "custom", data: { anonymous: true } }) + expect((yield* catalog.provider.available()).map((provider) => provider.id)).toContain( + ProviderV2.ID.make("kilo"), + ) + expect(result.request.body.apiKey).toBe("anonymous") expect(result.request.body.kilocodeToken).toBe("anonymous") }), ), diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index 5ffc6d7879..f92d5fc0bb 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -1,6 +1,7 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { Catalog } from "@opencode-ai/core/catalog" +import { Integration } from "@opencode-ai/core/integration" import { PluginV2 } from "@opencode-ai/core/plugin" import { ProviderPlugins } from "@opencode-ai/core/plugin/provider" import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway" @@ -8,6 +9,14 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { expectPluginRegistered, it, provider } from "./provider-helper" describe("LLMGatewayPlugin", () => { + const add = Effect.fnUntraced(function* (plugin: PluginV2.Interface) { + const integrations = yield* Integration.Service + yield* plugin.add({ + ...LLMGatewayPlugin, + effect: LLMGatewayPlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), + }) + }) + it.effect("is registered so legacy referer headers can be applied", () => Effect.sync(() => expectPluginRegistered( @@ -21,31 +30,29 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(LLMGatewayPlugin) + yield* add(plugin) + const integrations = yield* Integration.Service + yield* integrations.update((editor) => { + editor.update(Integration.ID.make("llmgateway"), () => {}) + editor.update(Integration.ID.make("openrouter"), () => {}) + }) const transform = yield* catalog.transform() yield* transform((catalog) => { const llmgateway = provider("llmgateway", { - enabled: { via: "env", name: "LLMGATEWAY_API_KEY" }, api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" }, request: { headers: { Existing: "value" }, body: {} }, }) catalog.provider.update(llmgateway.id, (draft) => { - draft.enabled = llmgateway.enabled draft.api = llmgateway.api draft.request = llmgateway.request }) - const openrouter = provider("openrouter", { - enabled: { via: "env", name: "OPENROUTER_API_KEY" }, - }) - catalog.provider.update(openrouter.id, (draft) => { - draft.enabled = openrouter.enabled - }) + catalog.provider.update(ProviderV2.ID.openrouter, () => {}) }) expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({ Existing: "value", "HTTP-Referer": "https://kilo.ai/", - "X-Title": "Kilo Code", - "X-Source": "kilo", + "X-Title": "Kilo Code", // kilocode_change + "X-Source": "kilo", // kilocode_change }) expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).request.headers).toEqual({}) }), @@ -55,7 +62,7 @@ describe("LLMGatewayPlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(LLMGatewayPlugin) + yield* add(plugin) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("llmgateway", { @@ -66,7 +73,7 @@ describe("LLMGatewayPlugin", () => { }) }) - expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false) + expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).disabled).toBeUndefined() expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({}) }), ) diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index a317ba4bf7..d30b585b96 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -21,16 +21,16 @@ describe("OpenAIPlugin", () => { const plugin = yield* PluginV2.Service yield* add(plugin, yield* Integration.Service) expect((yield* (yield* Integration.Service).get(Integration.ID.make("openai")))?.methods).toEqual([ - new Integration.OAuthMethod({ + { id: Integration.MethodID.make("chatgpt-browser"), type: "oauth", label: "ChatGPT Pro/Plus (browser)", - }), - new Integration.OAuthMethod({ + }, + { id: Integration.MethodID.make("chatgpt-headless"), type: "oauth", label: "ChatGPT Pro/Plus (headless)", - }), + }, ]) }), ) diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index d12f04c0c9..01cabf3581 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -3,6 +3,7 @@ import { DateTime, Effect, Layer, Option } from "effect" import { Catalog } from "@opencode-ai/core/catalog" import { Credential } from "@opencode-ai/core/credential" import { EventV2 } from "@opencode-ai/core/event" +import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { PluginV2 } from "@opencode-ai/core/plugin" @@ -18,13 +19,18 @@ const locationLayer = Layer.succeed( Location.Service.of(location({ directory: AbsolutePath.make("test") })), ) +const pluginWithIntegrations = (integrations: Integration.Interface) => ({ + ...OpencodePlugin, + effect: OpencodePlugin.effect.pipe(Effect.provideService(Integration.Service, integrations)), +}) + describe("OpencodePlugin", () => { it.effect("uses a public key and disables paid models without credentials", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -45,7 +51,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -66,7 +72,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -87,7 +93,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode") @@ -108,13 +114,18 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + const integrations = yield* Integration.Service + yield* plugin.add(pluginWithIntegrations(integrations)) + yield* integrations.update((editor) => { + editor.method.update({ + integrationID: Integration.ID.make("opencode"), + method: { type: "env", names: ["CUSTOM_OPENCODE_API_KEY"] }, + }) + }) const transform = yield* catalog.transform() yield* transform((catalog) => { - const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }) - catalog.provider.update(item.id, (draft) => { - draft.env = [...item.env] - }) + const item = provider("opencode") + catalog.provider.update(item.id, () => {}) const paid = model("opencode", "paid", { cost: cost(1) }) catalog.model.update(item.id, paid.id, (draft) => { draft.cost = [...paid.cost] @@ -131,7 +142,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("opencode", { @@ -154,37 +165,12 @@ describe("OpencodePlugin", () => { ), ) - it.effect("uses auth-enabled providers as credentials", () => - withEnv({ OPENCODE_API_KEY: undefined }, () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) - const transform = yield* catalog.transform() - yield* transform((catalog) => { - const item = provider("opencode", { - enabled: { via: "credential", credentialID: Credential.ID.make("credential") }, - }) - catalog.provider.update(item.id, (draft) => { - draft.enabled = item.enabled - }) - const paid = model("opencode", "paid", { cost: cost(1) }) - catalog.model.update(item.id, paid.id, (draft) => { - draft.cost = [...paid.cost] - }) - }) - expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).request.body.apiKey).toBeUndefined() - expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true) - }), - ), - ) - it.effect("ignores non-opencode providers and models", () => withEnv({ OPENCODE_API_KEY: undefined }, () => Effect.gen(function* () { const plugin = yield* PluginV2.Service const catalog = yield* Catalog.Service - yield* plugin.add(OpencodePlugin) + yield* plugin.add(pluginWithIntegrations(yield* Integration.Service)) const transform = yield* catalog.transform() yield* transform((catalog) => { const item = provider("openai") diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index 823d8d842e..47f2176c37 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -365,6 +365,18 @@ describe("ProjectCopy", () => { }), ) + it.live("refresh ignores existing directories that are no longer git checkouts", () => + Effect.gen(function* () { + const input = yield* setup() + yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true })) + const copy = yield* ProjectCopy.Service + + yield* copy.refresh({ projectID: input.projectID }) + + expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }]) + }), + ) + it.live("refresh with no roots is a no-op", () => Effect.gen(function* () { const copy = yield* ProjectCopy.Service diff --git a/packages/core/test/pty/info-schema.test.ts b/packages/core/test/pty/info-schema.test.ts index 9f58c45c88..6cdf263a5b 100644 --- a/packages/core/test/pty/info-schema.test.ts +++ b/packages/core/test/pty/info-schema.test.ts @@ -24,4 +24,9 @@ describe("Pty.Info", () => { test("rejects a negative pid", () => { expect(() => Schema.decodeUnknownSync(Pty.Info)(sample(-1))).toThrow() }) + + test("accepts an exit code for retained exited sessions", () => { + const info = Schema.decodeUnknownSync(Pty.Info)({ ...sample(48012), status: "exited", exitCode: 4 }) + expect(info.exitCode).toBe(4) + }) }) diff --git a/packages/core/test/pty/input.test.ts b/packages/core/test/pty/input.test.ts deleted file mode 100644 index 2cfe9756b0..0000000000 --- a/packages/core/test/pty/input.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { handlePtyInput } from "@opencode-ai/core/pty/input" -import { it } from "../lib/effect" - -describe("pty websocket input", () => { - it.effect("does not forward invalid binary frames to the PTY handler", () => - Effect.gen(function* () { - const messages: Array = [] - const handler = { onMessage: (message: string | ArrayBuffer) => messages.push(message) } - - yield* handlePtyInput(handler, "ready") - yield* handlePtyInput(handler, new Uint8Array([0xff, 0xfe, 0xfd])) - yield* handlePtyInput(handler, new TextEncoder().encode("hello")) - - expect(messages).toEqual(["ready", "hello"]) - }), - ) -}) diff --git a/packages/core/test/pty/protocol.test.ts b/packages/core/test/pty/protocol.test.ts new file mode 100644 index 0000000000..2bf9610595 --- /dev/null +++ b/packages/core/test/pty/protocol.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { PtyProtocol } from "@opencode-ai/core/pty/protocol" + +describe("pty protocol", () => { + test("drops invalid binary input frames and decodes valid ones", () => { + expect(PtyProtocol.decodeInput("ready")).toBe("ready") + expect(PtyProtocol.decodeInput(new Uint8Array([0xff, 0xfe, 0xfd]))).toBeUndefined() + expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello"))).toBe("hello") + expect(PtyProtocol.decodeInput(new TextEncoder().encode("hello").buffer)).toBe("hello") + }) + + test("encodes the cursor as a 0x00-prefixed JSON control frame", () => { + const frame = PtyProtocol.metaFrame(42) + expect(frame[0]).toBe(0) + expect(JSON.parse(new TextDecoder().decode(frame.subarray(1)))).toEqual({ cursor: 42 }) + }) + + test("splits replay into bounded frames", () => { + expect(PtyProtocol.chunks("")).toEqual([]) + expect(PtyProtocol.chunks("abc")).toEqual(["abc"]) + const big = "x".repeat(PtyProtocol.REPLAY_CHUNK + 1) + const frames = PtyProtocol.chunks(big) + expect(frames.length).toBe(2) + expect(frames[0].length).toBe(PtyProtocol.REPLAY_CHUNK) + expect(frames.join("")).toBe(big) + }) +}) diff --git a/packages/core/test/pty/pty-output-isolation.test.ts b/packages/core/test/pty/pty-output-isolation.test.ts deleted file mode 100644 index 6e4d1f08d6..0000000000 --- a/packages/core/test/pty/pty-output-isolation.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect } from "bun:test" -import { Duration, Effect, Layer, Queue } from "effect" -import { EventV2 } from "@opencode-ai/core/event" -import { Location } from "@opencode-ai/core/location" -import { Pty } from "@opencode-ai/core/pty" -import { AbsolutePath } from "@opencode-ai/core/schema" -import { location } from "../fixture/location" -import { testEffect } from "../lib/effect" - -type Socket = Parameters[1] - -const locationLayer = Layer.succeed( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), -) -const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer))) -const ptyTest = process.platform === "win32" ? it.live.skip : it.live - -const createPty = Effect.fn("PtyOutputIsolationTest.createPty")(function* (command: string) { - const pty = yield* Pty.Service - return yield* Effect.acquireRelease( - pty.create({ command, args: [], cwd: "/tmp", env: { TERM: "xterm-256color", KILO_TERMINAL: "1" } }), - (info) => pty.remove(info.id).pipe(Effect.ignore), - ) -}) - -const decodeOutput = (data: string | Uint8Array | ArrayBuffer) => - typeof data === "string" - ? data - : Buffer.from(data instanceof Uint8Array ? data : new Uint8Array(data)).toString("utf8") - -const makeSocket = Effect.fn("PtyOutputIsolationTest.makeSocket")(function* (data: unknown) { - const output = yield* Queue.unbounded() - const socket: Socket = { - readyState: 1, - data, - send: (data) => Queue.offerUnsafe(output, decodeOutput(data)), - close: () => {}, - } - return { socket, output } -}) - -const waitForOutput = (output: Queue.Queue, text: string, duration: Duration.Input = "5 seconds") => - Effect.gen(function* () { - let received = "" - while (!received.includes(text)) received += yield* Queue.take(output) - return received - }).pipe( - Effect.timeoutOrElse({ - duration, - orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), - }), - ) - -describe("pty output isolation", () => { - ptyTest("does not leak output when websocket objects are reused", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const a = yield* createPty("cat") - const b = yield* createPty("cat") - const shared = yield* makeSocket({ events: { connection: "a" } }) - const outB = yield* Queue.unbounded() - - yield* pty.connect(a.id, shared.socket) - shared.socket.data = { events: { connection: "b" } } - shared.socket.send = (data) => Queue.offerUnsafe(outB, decodeOutput(data)) - yield* pty.connect(b.id, shared.socket) - yield* pty.write(a.id, "AAA\n") - - const verify = yield* makeSocket({ events: { connection: "verify-a" } }) - yield* pty.connect(a.id, verify.socket) - expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA") - expect(yield* waitForOutput(outB, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" }) - }), - ) - - ptyTest("does not leak output when Bun recycles websocket objects before re-connect", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* createPty("cat") - const first = yield* makeSocket({ events: { connection: "a" } }) - const recycled = yield* Queue.unbounded() - - yield* pty.connect(info.id, first.socket) - first.socket.data = { events: { connection: "b" } } - first.socket.send = (data) => Queue.offerUnsafe(recycled, decodeOutput(data)) - yield* pty.write(info.id, "AAA\n") - - const verify = yield* makeSocket({ events: { connection: "verify" } }) - yield* pty.connect(info.id, verify.socket) - expect(yield* waitForOutput(verify.output, "AAA")).toContain("AAA") - expect(yield* waitForOutput(recycled, "AAA", "100 millis").pipe(Effect.option)).toMatchObject({ _tag: "None" }) - }), - ) - - ptyTest("treats in-place socket data mutation as the same connection", () => - Effect.gen(function* () { - const pty = yield* Pty.Service - const info = yield* createPty("cat") - const data = { connId: 1 } - const socket = yield* makeSocket(data) - - yield* pty.connect(info.id, socket.socket) - data.connId = 2 - yield* pty.write(info.id, "AAA\n") - - expect(yield* waitForOutput(socket.output, "AAA")).toContain("AAA") - }), - ) -}) diff --git a/packages/core/test/pty/pty-session.test.ts b/packages/core/test/pty/pty-session.test.ts index 3372442dbf..6b78ec4d13 100644 --- a/packages/core/test/pty/pty-session.test.ts +++ b/packages/core/test/pty/pty-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" -import { Cause, Effect, Exit, Layer, Queue } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Queue } from "effect" +import { Config } from "@opencode-ai/core/config" import { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { Pty } from "@opencode-ai/core/pty" @@ -14,7 +15,14 @@ const locationLayer = Layer.succeed( Location.Service, Location.Service.of(location({ directory: AbsolutePath.make("/tmp") })), ) -const it = testEffect(Pty.layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provideMerge(locationLayer))) +const configLayer = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) }) +const it = testEffect( + Pty.layer.pipe( + Layer.provide(configLayer), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) const ptyTest = process.platform === "win32" ? it.live.skip : it.live const subscribePtyEvents = Effect.fn("PtySessionTest.subscribePtyEvents")(function* () { @@ -56,36 +64,176 @@ const waitForEvents = (events: Queue.Queue, id: PtyID, count: number) }), ) +const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) { + const pty = yield* Pty.Service + const output = yield* Queue.unbounded() + const ended = yield* Deferred.make<{ exitCode?: number }>() + const attachment = yield* pty.attach(id, { + cursor, + onData: (chunk) => Queue.offerUnsafe(output, chunk), + onEnd: (event) => Deferred.doneUnsafe(ended, Effect.succeed(event)), + }) + attachment.activate() + return { attachment, output, ended } +}) + +const waitForOutput = (output: Queue.Queue, text: string) => + Effect.gen(function* () { + let received = "" + while (!received.includes(text)) received += yield* Queue.take(output) + return received + }).pipe( + Effect.timeoutOrElse({ + duration: "5 seconds", + orElse: () => Effect.fail(new Error(`timeout waiting for output containing ${JSON.stringify(text)}`)), + }), + ) + describe("pty", () => { it.live("returns typed not found errors for missing sessions", () => Effect.gen(function* () { const pty = yield* Pty.Service const id = "pty_missing" as PtyID - let closed = false - const socket = { readyState: 1, send: () => {}, close: () => void (closed = true) } for (const result of [ yield* pty.get(id).pipe(Effect.asVoid, Effect.exit), yield* pty.update(id, { title: "missing" }).pipe(Effect.asVoid, Effect.exit), yield* pty.remove(id).pipe(Effect.exit), - yield* pty.resize(id, 80, 24).pipe(Effect.exit), yield* pty.write(id, "input").pipe(Effect.exit), - yield* pty.connect(id, socket).pipe(Effect.asVoid, Effect.exit), + yield* pty.attach(id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.asVoid, Effect.exit), ]) { expect(Exit.isFailure(result)).toBe(true) if (Exit.isFailure(result)) expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.NotFoundError", ptyID: id }) } - expect(closed).toBe(true) }), ) - ptyTest("publishes created, exited, deleted in order for a short-lived process", () => + ptyTest("retains exited sessions until removed", () => Effect.gen(function* () { + const pty = yield* Pty.Service const events = yield* subscribePtyEvents() - const info = yield* createPty("/usr/bin/env", ["sh", "-c", "sleep 0.1"]) + const info = yield* createPty("/usr/bin/env", ["sh", "-c", "exit 3"]) - expect(yield* waitForEvents(events, info.id, 3)).toEqual(["created", "exited", "deleted"]) + expect(yield* waitForEvents(events, info.id, 2)).toEqual(["created", "exited"]) + const exited = yield* pty.get(info.id) + expect(exited.status).toBe("exited") + expect(exited.exitCode).toBe(3) + + yield* pty.remove(info.id) + expect(yield* waitForEvents(events, info.id, 1)).toEqual(["deleted"]) + const missing = yield* pty.get(info.id).pipe(Effect.exit) + expect(Exit.isFailure(missing)).toBe(true) + }), + ) + + ptyTest("replays buffered output and streams live output to attachments", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("cat") + yield* pty.write(info.id, "AAA\n") + + const first = yield* attachCollecting(info.id) + expect(yield* waitForOutput(first.output, "AAA")).toContain("AAA") + + first.attachment.write("BBB\n") + yield* waitForOutput(first.output, "BBB") + + // A later attachment replays everything already buffered. + const replayed = yield* attachCollecting(info.id) + expect(replayed.attachment.replay).toContain("AAA") + expect(replayed.attachment.replay).toContain("BBB") + expect(replayed.attachment.cursor).toBeGreaterThan(0) + + // Tail attachments skip the buffer and only see subsequent output. + const tail = yield* attachCollecting(info.id, -1) + expect(tail.attachment.replay).toBe("") + expect(tail.attachment.cursor).toBe(replayed.attachment.cursor) + }), + ) + + ptyTest("stops delivering output after detach", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const info = yield* createPty("cat") + const attached = yield* attachCollecting(info.id, -1) + + attached.attachment.detach() + yield* pty.write(info.id, "AAA\n") + + const verify = yield* attachCollecting(info.id) + yield* waitForOutput(verify.output, "AAA") + const leaked = yield* Queue.poll(attached.output) + expect(leaked._tag).toBe("None") + }), + ) + + ptyTest("isolates output between sessions", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const a = yield* createPty("cat") + const b = yield* createPty("cat") + const attachedA = yield* attachCollecting(a.id) + const attachedB = yield* attachCollecting(b.id) + + yield* pty.write(a.id, "AAA\n") + yield* waitForOutput(attachedA.output, "AAA") + + const leaked = yield* Queue.poll(attachedB.output) + expect(leaked._tag).toBe("None") + }), + ) + + ptyTest("notifies attachments with the exit code and rejects attach after exit", () => + Effect.gen(function* () { + const pty = yield* Pty.Service + const events = yield* subscribePtyEvents() + const info = yield* createPty("cat") + const attached = yield* attachCollecting(info.id) + + yield* pty.write(info.id, "\u0004") + expect(yield* Deferred.await(attached.ended).pipe(Effect.timeout("5 seconds"))).toEqual({ exitCode: 0 }) + yield* waitForEvents(events, info.id, 2) + + const result = yield* pty.attach(info.id, { onData: () => {}, onEnd: () => {} }).pipe(Effect.exit) + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) + expect(Cause.squash(result.cause)).toMatchObject({ _tag: "Pty.ExitedError", ptyID: info.id }) + }), + ) +}) + +const configuredShell = process.platform === "win32" ? undefined : Bun.which("bash") +const configuredIt = testEffect( + Pty.layer.pipe( + Layer.provide( + Layer.mock(Config.Service)({ + entries: () => + Effect.succeed( + configuredShell + ? [new Config.Document({ type: "document", info: new Config.Info({ shell: configuredShell }) })] + : [], + ), + }), + ), + Layer.provideMerge(EventV2.defaultLayer), + Layer.provideMerge(locationLayer), + ), +) +const configuredTest = process.platform === "win32" ? configuredIt.live.skip : configuredIt.live + +describe("pty create defaults", () => { + configuredTest("defaults command, login args, and cwd from config and location", () => + Effect.gen(function* () { + if (!configuredShell) return + const pty = yield* Pty.Service + const info = yield* Effect.acquireRelease(pty.create({ title: "configured" }), (created) => + pty.remove(created.id).pipe(Effect.ignore), + ) + expect(info.command).toBe(configuredShell) + expect(info.args).toEqual(["-l"]) + expect(info.cwd).toBe("/tmp") + expect(info.title).toBe("configured") }), ) }) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/session-runner-model.test.ts index c50e2f855a..e1f5e32b75 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/session-runner-model.test.ts @@ -3,6 +3,8 @@ import { LLM } from "@opencode-ai/llm" import { LLMClient } from "@opencode-ai/llm/route" import { ConfigProvider, DateTime, Effect } from "effect" import { Headers } from "effect/unstable/http" +import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { ProjectV2 } from "@opencode-ai/core/project" @@ -45,8 +47,6 @@ const provider = (api: ProviderV2.Info["api"]) => new ProviderV2.Info({ id: ProviderV2.ID.make("test-provider"), name: "Test provider", - enabled: { via: "env", name: "TEST_PROVIDER_API_KEY" }, - env: ["TEST_PROVIDER_API_KEY"], api, request: { headers: {}, body: {} }, }) @@ -247,7 +247,7 @@ describe("SessionRunnerModel", () => { ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), request: { headers: {}, body: {}, generation: {}, options: {} }, }), - provider({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + { type: "env", name: "TEST_PROVIDER_API_KEY" }, ) const request = LLM.request({ model: resolved, prompt: "Hello" }) const headers = yield* resolved.route.auth @@ -266,6 +266,35 @@ describe("SessionRunnerModel", () => { }), ) + it.effect("prefers stored credentials over configured auth", () => + Effect.gen(function* () { + const credential = new Credential.Stored({ + id: Credential.ID.create(), + integrationID: Integration.ID.make("test-provider"), + label: "Work", + value: new Credential.Key({ type: "key", key: "stored-secret", metadata: { tenant: "work" } }), + }) + const resolved = yield* SessionRunnerModel.fromCatalogModel( + new ModelV2.Info({ + ...model({ type: "aisdk", package: "@ai-sdk/openai", url: "https://openai.example/v1" }), + request: { headers: {}, body: { apiKey: "configured-secret" }, generation: {}, options: {} }, + }), + { type: "credential", id: credential.id, label: credential.label }, + credential, + ) + const headers = yield* resolved.route.auth.apply({ + request: LLM.request({ model: resolved, prompt: "Hello" }), + method: "POST", + url: "https://openai.example/v1/responses", + body: "{}", + headers: Headers.empty, + }) + + expect(headers.authorization).toBe("Bearer stored-secret") + expect(resolved.route.defaults.http?.body).toEqual({ tenant: "work" }) + }), + ) + it.effect("rejects catalog APIs without a native route", () => Effect.gen(function* () { const failure = yield* SessionRunnerModel.fromCatalogModel( diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index cfec091861..39bf8433cd 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -704,7 +704,7 @@ describe("SessionRunnerLLM", () => { yield* events.publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change }) expect( yield* db @@ -762,7 +762,7 @@ describe("SessionRunnerLLM", () => { .publish(SessionEvent.Moved, { sessionID, timestamp: DateTime.makeUnsafe(1), - location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), + location: Location.Ref.make({ directory: AbsolutePath.make("/moved") }), // kilocode_change }) .pipe(Effect.asVoid) }) @@ -1461,6 +1461,7 @@ describe("SessionRunnerLLM", () => { }) requests.length = 0 + executions.length = 0 responses = [ fragmentFixture("text", "text-summary-2", ["## Goal\n- Preserve the updated task"]).completeEvents, fragmentFixture("text", "text-final-2", ["Continued again"]).completeEvents, @@ -3177,7 +3178,7 @@ describe("SessionRunnerLLM", () => { }), ) - it.effect("fails after the bounded number of local tool continuation steps", () => + it.effect("continues past 25 local tool steps when the agent has no step limit", () => Effect.gen(function* () { yield* setup const session = yield* SessionV2.Service @@ -3188,62 +3189,10 @@ describe("SessionRunnerLLM", () => { executions.length = 0 streamGate = undefined streamStarted = undefined - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - - const failure = yield* session.resume(sessionID).pipe(Effect.flip) - - expect(failure).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError", sessionID, limit: 25 }) - expect(requests).toHaveLength(25) - expect(executions).toHaveLength(25) - }), - ) - - it.effect("does not restart a capped tool loop for a coalesced stale wake", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - const coordinator = yield* SessionRunCoordinator.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Loop forever" }), resume: false }) - - requests.length = 0 - responses = Array.from({ length: 25 }, (_, index) => [ - LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-capped-${index}`, name: "echo", input: { text: `${index}` } }), - LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), - LLMEvent.finish({ reason: "tool-calls" }), - ]) - streamGate = yield* Deferred.make() - streamStarted = yield* Deferred.make() - - const run = yield* session.resume(sessionID).pipe(Effect.forkChild) - yield* Deferred.await(streamStarted) - yield* coordinator.wake(sessionID) - yield* Deferred.succeed(streamGate, undefined) - expect(yield* Fiber.join(run).pipe(Effect.flip)).toMatchObject({ _tag: "SessionRunner.StepLimitExceededError" }) - streamGate = undefined - streamStarted = undefined - yield* Effect.yieldNow - - expect(requests).toHaveLength(25) - }), - ) - - it.effect("accepts a terminal response on the final bounded provider turn", () => - Effect.gen(function* () { - yield* setup - const session = yield* SessionV2.Service - yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) - - requests.length = 0 responses = [ - ...Array.from({ length: 24 }, (_, index) => [ + ...Array.from({ length: 25 }, (_, index) => [ LLMEvent.stepStart({ index: 0 }), - LLMEvent.toolCall({ id: `call-terminal-${index}`, name: "echo", input: { text: `${index}` } }), + LLMEvent.toolCall({ id: `call-echo-${index}`, name: "echo", input: { text: `${index}` } }), LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), LLMEvent.finish({ reason: "tool-calls" }), ]), @@ -3256,7 +3205,56 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) - expect(requests).toHaveLength(25) + expect(requests).toHaveLength(26) + expect(executions).toHaveLength(25) + }), + ) + + it.effect("forces a text response on an agent's configured final step", () => + Effect.gen(function* () { + yield* setup + const agents = yield* AgentV2.Service + yield* agents.update((editor) => + editor.update(AgentV2.ID.make("build"), (agent) => { + agent.steps = 2 + }), + ) + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Finish at the limit" }), resume: false }) + + requests.length = 0 + executions.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-terminal", name: "echo", input: { text: "done" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.toolCall({ id: "call-forbidden", name: "echo", input: { text: "forbidden" } }), + LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }), + LLMEvent.finish({ reason: "tool-calls" }), + ], + ] + + yield* session.resume(sessionID) + + expect(requests).toHaveLength(2) + expect(requests[0]?.toolChoice).toBeUndefined() + expect(requests[1]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[1]?.tools).toEqual([]) + expect(requests[1]?.messages.at(-1)).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: expect.stringContaining("MAXIMUM STEPS REACHED") }], + }) + expect(executions).toEqual(["done"]) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Finish at the limit" }, + { type: "assistant", content: [{ type: "tool", id: "call-terminal", state: { status: "completed" } }] }, + { type: "assistant", content: [{ type: "tool", id: "call-forbidden", state: { status: "error" } }] }, + ]) }), ) diff --git a/packages/opencode/test/shell/shell.test.ts b/packages/core/test/shell.test.ts similarity index 85% rename from packages/opencode/test/shell/shell.test.ts rename to packages/core/test/shell.test.ts index 1f76783ac1..1cc47a79f6 100644 --- a/packages/opencode/test/shell/shell.test.ts +++ b/packages/core/test/shell.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { Shell } from "../../src/shell/shell" -import { Filesystem } from "@/util/filesystem" +import { Shell } from "@opencode-ai/core/shell" +import { FSUtil } from "@opencode-ai/core/fs-util" import { which } from "@opencode-ai/core/util/which" const withShell = async (shell: string | undefined, fn: () => void | Promise) => { @@ -54,6 +54,15 @@ describe("shell", () => { expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu") }) + test("builds command args per shell family", () => { + expect(Shell.args("/bin/sh", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + expect(Shell.args("/usr/bin/fish", "echo hi", "/tmp")).toEqual(["-c", "echo hi"]) + const zsh = Shell.args("/bin/zsh", "echo hi", "/tmp") + expect(zsh[0]).toBe("-l") + expect(zsh[1]).toBe("-c") + expect(zsh.at(-1)).toBe("/tmp") + }) + if (process.platform === "win32") { test("rejects blacklisted shells case-insensitively", async () => { await withShell("NU.EXE", async () => { @@ -64,7 +73,7 @@ describe("shell", () => { test("normalizes Git Bash shell paths from env", async () => { const shell = "/cygdrive/c/Program Files/Git/bin/bash.exe" await withShell(shell, async () => { - expect(Shell.preferred()).toBe(Filesystem.windowsPath(shell)) + expect(Shell.preferred()).toBe(FSUtil.windowsPath(shell)) }) }) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 021b5b4e90..920418a989 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -7,8 +7,8 @@ "private": true, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.ts", diff --git a/packages/effect-sqlite-node/sst-env.d.ts b/packages/effect-sqlite-node/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/effect-sqlite-node/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 6eadd75534..fec3e374d0 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -28,10 +28,10 @@ }, "scripts": { "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit", "build": "bun ./script/build.ts", - "verify:package": "bun ./script/verify-package.ts" + "verify:package": "bun ./script/verify-package.ts", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.ts", diff --git a/packages/http-recorder/sst-env.d.ts b/packages/http-recorder/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/http-recorder/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index 73dc8a11fb..bfff8a2920 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -60,6 +60,9 @@ exclude = [ # Consistently times out in CI '^https?://opncd\.ai', '^https?://zod\.dev/v4/changelog', + # Moonshot sites consistently time out from GitHub Actions runners. + '^https?://(www\.)?moonshot\.cn/?$', + '^https?://platform\.moonshot\.cn/?$', # Example punycode domain used in homograph attack documentation — does not exist '^https?://xn--pitest', # OpenAI docs return 404 to plain GET link checks but resolve in browsers diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index a78b4ae4a3..40e1109389 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -159,6 +159,10 @@ When a pull request or merge request is opened or updated: Reviews are posted directly in your platform (GitHub or GitLab) as if coming from a team reviewer. +{% callout type="info" title="Bot-generated PRs are ignored by default" %} +Kilo does not automatically review pull or merge requests opened by bots, such as Dependabot, Renovate, or other automation accounts. This keeps review credits and notifications focused on human-authored changes. +{% /callout %} + ## Review Styles ### Strict @@ -236,3 +240,4 @@ The Review Agent is ideal for: - Some highly dynamic or domain-specific code may require additional context in `REVIEW.md`. - The agent will only run on **selected repositories**. - During beta, review capacity may be throttled for extremely large PRs. +- PRs/MRs opened by bots (e.g. Dependabot, Renovate) are ignored by default. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index d489dc63eb..20ad709908 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -387,7 +387,7 @@ You can also set options that apply to all models from a provider: |---|---|---| | `apiKey` | `string` | API key (supports `{env:VAR}` and `{file:...}` syntax in trusted config — see note below) | | `baseURL` | `string` | Override the provider's base API URL | -| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | +| `timeout` | `number \| false` | Request timeout in milliseconds, covering both the wait for response headers and the wait for the first byte of the response body. Defaults to `300000` (5 minutes); set to `false` to disable. Once data starts arriving the timeout no longer applies, so slow streaming responses are never cut short — use `chunkTimeout` for gaps inside a response | | `chunkTimeout` | `number` | Timeout in milliseconds between streamed response chunks. If no chunk arrives within this window, the request is aborted and retried. This catches silent provider dropouts where the TCP connection stays open but SSE streaming stops. Recommended: `15000`–`30000` (15–30 seconds) for providers with unreliable streaming. | {% callout type="warning" title="{env:} / {file:} only resolve in trusted config" %} diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index 0438ce9395..ac6b5490c0 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e8318f427b6c0ff71d4cce5c8ac03ba4778f99fda5723d24d23c38471523b622 -size 52203 +oid sha256:a7c800d169ca92674fc9f7c7d83032ca17e7d7937bdcbea5c72cb921a7fdb89e +size 51975 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png index 37a0a3a3a8..85a8ddb7b6 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1bd31cd4e01c2e2a5ed579bce1b542368e7a11fc878d375a93fb238ba4f82469 -size 53288 +oid sha256:a7d7d047f14440d3dafc8a202131af6e755f80875012fd934a56580de21d36d5 +size 52887 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png index 17e37e9de1..5568f063ac 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-collapsed-context-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:47b7b7bfb4a6b5631d8fdb808c1b3a9eb631b6f96911ddf15880f0c2fee4f7e0 -size 38558 +oid sha256:29e79ac79e992bf7f19646aab8ba10464872f4d012abed458a2a432a223668da +size 38129 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png index f38949019a..c2f3c6d8b7 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e872bc8ad05ad2a7ad1dd6de8a2ff427957fd278cee46940db0b12d84fe42ecf -size 25534 +oid sha256:ee6ea794cf665570dcc208ad5975a14843d1680e59eb785fb5298b4919b7dd1d +size 25757 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png index 0eff7b16fa..70ea402378 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff33e800f4d2379bcc997228a1e4dd83433521b931ae85edb7c8f0c4c93405e7 -size 20540 +oid sha256:ee9fb41fb522684de6aa7fd9acda8357f33f2ba90bcb3eb7679ddfbcdecc6cdb +size 20764 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png new file mode 100644 index 0000000000..17c6284a46 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77964c111d0454d7985e3368b821e161e4d6d9fffee5438ee094ab34ff464d52 +size 18623 diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index dd9239dbb4..7c48f6085e 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -102,6 +102,22 @@ ## [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 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index d7c2be2cdf..4ebd49744e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -65,6 +65,7 @@ class KiloBackendChatManager( "session.status", "session.updated", "session.idle", + "session.queue.changed", "session.compacted", "session.diff", "permission.asked", @@ -90,14 +91,15 @@ class KiloBackendChatManager( if (watcher?.isActive == true) return watcher = cs.launch { sse.collect { event -> - if (event.type in CHAT_EVENTS) { + val type = if (event.type in CHAT_EVENTS) event.type else KiloCliDataParser.extractEventType(event.data) + if (type in CHAT_EVENTS) { val events = try { - normalizer.parse(event.type, event.data) + normalizer.parse(type, event.data) } catch (e: CancellationException) { throw e } catch (e: Exception) { log.warn( - "route=chat-events parse=false type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", + "route=chat-events parse=false type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}", e, ) return@collect @@ -120,7 +122,7 @@ class KiloBackendChatManager( _events.emit(parsed) } } else { - log.warn("route=chat-events parse=null type=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") + log.warn("route=chat-events parse=null type=$type raw=${event.type} bytes=${event.data.length} ${ChatLogSummary.body(event.data)}") } } } @@ -264,6 +266,27 @@ class KiloBackendChatManager( postCancellable("/session/$id/revert?directory=${encode(dir)}", body, "revert", "${ChatLogSummary.sid(id)} kind=revert") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean { + log.info("${ChatLogSummary.sid(id)} kind=deleteMessage ${ChatLogSummary.dir(dir)} message=$message") + val http = requireClient() + val url = requireBase() + val request = Request.Builder() + .url("$url/session/$id/message/$message?directory=${encode(dir)}") + .delete() + .build() + val call = http.newCall(request) + call.timeout().timeout(REVERT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + return call.await().use { response -> + val raw = response.body?.string().orEmpty().trim() + if (!response.isSuccessful) { + log.warn("deleteMessage failed: HTTP ${response.code}") + raw.takeIf { it.isNotBlank() }?.let { log.debug { "${ChatLogSummary.sid(id)} kind=deleteMessage error=${ChatLogSummary.body(it)}" } } + return@use false + } + raw != "false" + } + } + suspend fun unrevert(id: String, dir: String) { log.info("${ChatLogSummary.sid(id)} kind=unrevert ${ChatLogSummary.dir(dir)}") postCancellable("/session/$id/unrevert?directory=${encode(dir)}", "{}", "unrevert", "${ChatLogSummary.sid(id)} kind=unrevert") diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt index a87fd32c6a..1d4fa2ee4b 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloCliDataParser.kt @@ -248,6 +248,12 @@ object KiloCliDataParser { ChatEventDto.SessionIdle(sid) } + "session.queue.changed" -> { + val sid = props.str("sessionID") ?: return null + val queued = props["queued"]?.jsonArray?.mapNotNull { it.jsonPrimitive.contentOrNull } ?: emptyList() + ChatEventDto.SessionQueueChanged(sid, queued) + } + "session.compacted" -> { val sid = props.str("sessionID") ?: return null ChatEventDto.SessionCompacted(sid) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index 531b66ed24..597404c665 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -132,6 +132,9 @@ class KiloSessionRpcApiImpl internal constructor( override suspend fun revert(id: String, directory: String, messageID: String, partID: String?) = ready { chat.revert(id, sessions.getDirectory(id, directory), messageID, partID) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean = + ready { chat.deleteMessage(id, sessions.getDirectory(id, directory), messageID) } + override suspend fun unrevert(id: String, directory: String) = ready { chat.unrevert(id, sessions.getDirectory(id, directory)) } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt index 0d62f668dc..3225fef1a0 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendChatManagerTest.kt @@ -90,6 +90,32 @@ class KiloBackendChatManagerTest { assertEquals("{}", mock.lastUnrevertBody) } + @Test + fun `delete message sends queued message delete request`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertTrue(result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + assertTrue(mock.lastMessageDeletePath!!.startsWith("/session/ses_abc/message/msg1?directory=")) + } + + @Test + fun `delete message returns false for queued drop miss`() = runBlocking { + val port = mock.start() + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, MutableSharedFlow()) + mock.messageDeleteResponse = "false" + + val result = chat.deleteMessage("ses_abc", "/test/project", "msg1") + + assertEquals(false, result) + assertEquals(1, mock.requestCount("/session/ses_abc/message/msg1")) + } + @Test fun `revert failure throws on non successful response`() = runBlocking { val port = mock.start() @@ -202,4 +228,21 @@ class KiloBackendChatManagerTest { assertEquals("ses_abc", event.sessionID) assertTrue(log.messages.any { it.contains("route=chat-events parse=false type=session.error") }, log.messages.joinToString("\n")) } + + @Test + fun `global message event type is extracted from payload`() = runBlocking { + val port = mock.start() + val sse = MutableSharedFlow(replay = 8) + val chat = KiloBackendChatManager(scope, TestLog()) + chat.start(OkHttpClient(), port, sse) + + val received = async(start = CoroutineStart.UNDISPATCHED) { withTimeout(5_000) { chat.events.first() } } + withTimeout(5_000) { sse.subscriptionCount.first { it > 0 } } + sse.emit(SseEvent("message", """{"payload":{"type":"session.queue.changed","properties":{"sessionID":"ses_abc","queued":["msg2"]}}}""")) + + val event = received.await() + assertTrue(event is ChatEventDto.SessionQueueChanged) + assertEquals("ses_abc", event.sessionID) + assertEquals(listOf("msg2"), event.queued) + } } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt index 8e71d6ce37..2a32a5060b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/KiloCliDataParserTest.kt @@ -651,6 +651,19 @@ class KiloCliDataParserTest { assertTrue(result is ChatEventDto.SessionCompacted) } + @Test + fun `parseChatEvent - session queue changed`() { + val data = globalEvent(""" + "type": "session.queue.changed", + "properties": { "sessionID": "ses_1", "queued": ["msg2", "msg3"] } + """) + val result = KiloCliDataParser.parseChatEvent("session.queue.changed", data) + assertNotNull(result) + assertTrue(result is ChatEventDto.SessionQueueChanged) + assertEquals("ses_1", result.sessionID) + assertEquals(listOf("msg2", "msg3"), result.queued) + } + @Test fun `parseChatEvent - session updated`() { val data = globalEvent(""" diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt index 5e05d25573..d016709a68 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/MockCliServer.kt @@ -117,11 +117,14 @@ class MockCliServer : AutoCloseable { @Volatile var lastCloudSessionImportBody: String? = null @Volatile var summarizeStatus = 200 @Volatile var revertStatus = 200 + @Volatile var messageDeleteStatus = 200 + @Volatile var messageDeleteResponse = "true" @Volatile var unrevertStatus = 200 @Volatile var lastSummarizePath: String? = null @Volatile var lastSummarizeBody: String? = null @Volatile var lastRevertPath: String? = null @Volatile var lastRevertBody: String? = null + @Volatile var lastMessageDeletePath: String? = null @Volatile var lastUnrevertPath: String? = null @Volatile var lastUnrevertBody: String? = null @Volatile var promptStatus = 200 @@ -438,6 +441,10 @@ class MockCliServer : AutoCloseable { lastRevertBody = body respond(output, revertStatus, sessionCreate) } + bare.matches(Regex("/session/ses_[^/]+/message/[^/]+")) && method == "DELETE" -> { + lastMessageDeletePath = path + respond(output, messageDeleteStatus, messageDeleteResponse) + } bare.matches(Regex("/session/ses_[^/]+/unrevert")) && method == "POST" -> { lastUnrevertPath = path lastUnrevertBody = body diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index c13a70c2b6..0ff583165a 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.intellij.platform.gradle.TestFrameworkType import org.jetbrains.intellij.platform.gradle.tasks.InstrumentCodeTask import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.PluginInstallationTarget +import java.io.File import java.time.LocalDate group = "ai.kilocode.jetbrains" @@ -207,8 +208,12 @@ intellijPlatform { } signing { + // CI passes raw secret content so signing can run without writing secrets to disk. + // Local release builds can still point these properties at pre-existing secret files. certificateChain = providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN") privateKey = providers.environmentVariable("JETBRAINS_PRIVATE_KEY") + certificateChainFile.fileProvider(providers.environmentVariable("JETBRAINS_CERTIFICATE_CHAIN_FILE").map { File(it) }) + privateKeyFile.fileProvider(providers.environmentVariable("JETBRAINS_PRIVATE_KEY_FILE").map { File(it) }) password = providers.environmentVariable("JETBRAINS_PRIVATE_KEY_PASSWORD") } @@ -220,6 +225,10 @@ intellijPlatform { } tasks { + named("verifyPluginSignature") { + dependsOn("signPlugin") + } + withType { enabled = false } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 2de1eb64de..1aef23518f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -202,6 +202,9 @@ class KiloSessionService internal constructor( log.info("${ChatLogSummary.sid(id)} kind=revert ok=true") } + suspend fun deleteMessage(id: String, dir: String, message: String): Boolean = + call { deleteMessage(id, dir, message) } + suspend fun unrevert(id: String, dir: String) { call { unrevert(id, dir) } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 8633382539..c59014686d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -367,6 +367,7 @@ class SessionUi( resize = { anchor, fn -> scroll.preserve(anchor, fn) }, revert = ::revert, cancelRevert = ::cancelRevert, + deleteQueued = { id -> controller.deleteQueuedMessage(id) }, banner = RevertBanner(controller.model, ::redo, controller::redoAll, ::cancelRevert, focus), ).also { it.onHover = { view, on -> if (on) popup.show(view) else popup.notifyExit(view) } @@ -543,6 +544,8 @@ class SessionUi( is SessionModelEvent.RevertChanged -> onRevertChanged(event.revert) + is SessionModelEvent.QueueChanged -> Unit + is SessionModelEvent.TurnAdded, is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 0570d4bd36..9b1e517ac6 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -464,6 +464,27 @@ class SessionController( } } + fun deleteQueuedMessage(message: String) { + assertEdt() + val id = sid ?: return + cs.launch { + try { + val ok = sessions.deleteMessage(id, directory, message) + if (!ok) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to "DeleteMiss")) + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage missed message=$message") + return@launch + } + capture("Conversation Queued Message Removed", sessionProps(id)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "delete-message", "errorClass" to e::class.java.name)) + LOG.warn("${ChatLogSummary.sid(id)} kind=deleteMessage failed message=${e.message}", e) + } + } + } + fun unrevert() { assertEdt() val id = sid ?: return @@ -1368,6 +1389,8 @@ class SessionController( idle() } + is ChatEventDto.SessionQueueChanged -> updateModel { model.setQueued(event.queued.toSet()) } + is ChatEventDto.SessionCompacted -> { capture("Context Condensed", sessionProps(event.sessionID)) model.markCompacted() @@ -1406,7 +1429,8 @@ class SessionController( is ChatEventDto.QuestionRejected, is ChatEventDto.SessionStatusChanged, is ChatEventDto.SessionUpdated, - is ChatEventDto.SessionIdle -> { + is ChatEventDto.SessionIdle, + is ChatEventDto.SessionQueueChanged -> { edt { if (disposed) return@edt updateModel { handleMetadata(event) } @@ -1428,6 +1452,7 @@ class SessionController( is ChatEventDto.SessionStatusChanged -> status(event.status) is ChatEventDto.SessionUpdated -> model.setSession(event.session) is ChatEventDto.SessionIdle -> idle() + is ChatEventDto.SessionQueueChanged -> model.setQueued(event.queued.toSet()) else -> Unit } } @@ -2312,6 +2337,7 @@ private fun matchesSession(event: ChatEventDto, id: String): Boolean = when (eve is ChatEventDto.SessionStatusChanged -> event.sessionID == id is ChatEventDto.SessionUpdated -> event.sessionID == id is ChatEventDto.SessionIdle -> event.sessionID == id + is ChatEventDto.SessionQueueChanged -> event.sessionID == id is ChatEventDto.SessionCompacted -> event.sessionID == id is ChatEventDto.SessionDiffChanged -> event.sessionID == id is ChatEventDto.TodoUpdated -> event.sessionID == id diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index fa9412ff3e..14f7b8c027 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -74,6 +74,9 @@ class SessionModel { private var revert: SessionRevertDto? = null + var queued: Set = emptySet() + private set + var header: SessionHeaderSnapshot = emptyHeader() private set @@ -125,6 +128,9 @@ class SessionModel { return idx >= 0 && pos >= idx } + @RequiresEdt + fun isQueued(id: String): Boolean = id in queued + @RequiresEdt fun turn(id: String): Turn? = turnEntries[id] @@ -295,6 +301,13 @@ class SessionModel { fire(SessionModelEvent.RevertChanged(revert)) } + @RequiresEdt + fun setQueued(ids: Set) { + if (queued == ids) return + queued = ids + fire(SessionModelEvent.QueueChanged(ids)) + } + @RequiresEdt fun setDiff(diff: List) { this.diff = diff @@ -329,6 +342,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() @@ -363,6 +377,7 @@ class SessionModel { hiddenText.clear() session = null revert = null + queued = emptySet() state = SessionState.Idle diff = emptyList() todos = emptyList() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index bc553cfe58..d3af00f638 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -60,6 +60,9 @@ sealed class SessionModelEvent { data class RevertChanged(val revert: SessionRevertDto?) : SessionModelEvent() { override fun toString() = "RevertChanged ${revert?.messageID ?: "none"}" } + data class QueueChanged(val queued: Set) : SessionModelEvent() { + override fun toString() = "QueueChanged [${queued.sorted().joinToString(", ")}]" + } data class HeaderUpdated(val header: SessionHeaderSnapshot) : SessionModelEvent() { override fun toString() = "HeaderUpdated visible=${header.visible}" } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index a0dc4d7dfd..3396a1c1ce 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -60,6 +60,7 @@ class SessionMessageListPanel( private val resize: ((JComponent, () -> Unit) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, private val cancelRevert: (() -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, private val banner: RevertBanner? = null, ) : SessionLayoutPanel( SessionUiStyle.SessionLayout.GAP, @@ -148,6 +149,12 @@ class SessionMessageListPanel( refresh() } + is SessionModelEvent.QueueChanged -> { + syncQueued() + syncSettled() + refresh() + } + // Message events: structural changes are handled via turn events above. is SessionModelEvent.MessageAdded, is SessionModelEvent.MessageUpdated, @@ -216,7 +223,7 @@ class SessionMessageListPanel( // ------ private event handlers ------ private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -224,6 +231,7 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() add(tv) syncSettled() @@ -251,6 +259,7 @@ class SessionMessageListPanel( register(id, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) syncReverted() syncSettled() @@ -279,7 +288,7 @@ class SessionMessageListPanel( removeAll() for (turn in model.turns()) { - val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert) + val tv = TurnView(turn.id, openFile, style, openUrl, selection, openAttachment, resize, repo, ::hover, revert, deleteQueued) turnViews[turn.id] = tv for (msgId in turn.messageIds) { val msg = model.message(msgId) ?: continue @@ -287,11 +296,13 @@ class SessionMessageListPanel( register(msgId, tv, mv) } tv.syncCopyToolbars() + syncQueued(tv) add(tv) } syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverted() syncReverting(model.state) banner?.update() @@ -321,6 +332,7 @@ class SessionMessageListPanel( removeAll() syncActive(model.state) syncSettled(model.state) + syncQueued() syncReverting(model.state) banner?.update() anchorFooter() @@ -384,10 +396,18 @@ class SessionMessageListPanel( } private fun syncSettled(state: SessionState = model.state) { - val active = if (state.isBusy()) turnViews.values.lastOrNull() else null + val active = if (state.isBusy()) turnViews.values.lastOrNull { !model.isQueued(it.id) } else null for (view in turnViews.values) view.setSettled(view !== active) } + private fun syncQueued() { + for (view in turnViews.values) syncQueued(view) + } + + private fun syncQueued(view: TurnView) { + view.setQueued(model.isQueued(view.id)) { id -> deleteQueued?.invoke(id) } + } + /** * Re-insert [question], [permission], [login], and [progress] as the last children * so active views always render after all turn views, and progress is last. diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt index d3a47c3260..96d2a8e768 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/header/SessionHeaderPanel.kt @@ -207,6 +207,7 @@ class SessionHeaderPanel( is SessionModelEvent.TodosUpdated, is SessionModelEvent.SessionUpdated, is SessionModelEvent.RevertChanged, + is SessionModelEvent.QueueChanged, is SessionModelEvent.Compacted, is SessionModelEvent.HistoryLoaded, is SessionModelEvent.Cleared, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt index ec5f7e7c1a..9a4fd04d14 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/prompt/PromptPanel.kt @@ -212,7 +212,7 @@ class PromptPanel( isFocusPainted = false addActionListener { syncTooltip() - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID + val id = if (busy && !hasDraft()) StopSessionAction.ID else SendPromptAction.ID val action = ActionManager.getInstance().getAction(id) ?: return@addActionListener val ctx = DataManager.getInstance().getDataContext(button) @@ -258,7 +258,7 @@ class PromptPanel( private var request = 0L override val isSendEnabled: Boolean - get() = ready && !busy && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) + get() = ready && !submitting && (text().isNotEmpty() || attachments.isNotEmpty()) override val isStopEnabled: Boolean get() = busy @@ -273,6 +273,7 @@ class PromptPanel( syncEditorHeight() triggerCompletion(e) syncHighlights() + syncButton() onChange() } }) @@ -418,7 +419,7 @@ class PromptPanel( fun setBusy(value: Boolean) { busy = value if (value) invalidateEnhancement() else syncEnhance() - button.icon = if (value) STOP_ICON else SEND_ICON + syncButton() syncTooltip() } @@ -628,6 +629,11 @@ class PromptPanel( } } + @RequiresEdt + private fun syncButton() { + button.icon = if (busy && !hasDraft()) STOP_ICON else SEND_ICON + } + @RequiresEdt private fun submit(src: String) { if (!isSendEnabled) return @@ -885,19 +891,20 @@ class PromptPanel( } private fun tooltip(): String { - val id = if (busy) StopSessionAction.ID else SendPromptAction.ID - val text = if (busy) { + val stop = busy && !hasDraft() + val id = if (stop) StopSessionAction.ID else SendPromptAction.ID + val text = if (stop) { KiloBundle.message("prompt.button.stop") } else { KiloBundle.message("prompt.button.send") } val tip = KeymapUtil.createTooltipText(text, id) - if (busy) return tip - val stop = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) - if (stop.isEmpty()) return tip + if (stop) return tip + val shortcut = KeymapUtil.getFirstKeyboardShortcutText(StopSessionAction.ID) + if (shortcut.isEmpty()) return tip return XmlStringUtil.wrapInHtml( XmlStringUtil.escapeString(tip) + "
" + - XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", stop)) + XmlStringUtil.escapeString(KiloBundle.message("prompt.button.send.tooltip.stop", shortcut)) ) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt index aed735293b..5ccc77850f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -19,13 +19,21 @@ import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyleTarget import ai.kilocode.client.session.views.base.PartView import ai.kilocode.client.session.ui.style.SessionUiStyle +import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.client.ui.ToolbarButtonAction import ai.kilocode.client.ui.layout.HAlign import ai.kilocode.client.ui.layout.VAlign import ai.kilocode.client.ui.layout.align +import ai.kilocode.client.ui.toolbarButton +import ai.kilocode.client.ui.UiStyle +import ai.kilocode.client.ui.layout.Stack +import com.intellij.icons.AllIcons import com.intellij.openapi.Disposable import com.intellij.openapi.util.Disposer +import com.intellij.ui.components.JBLabel import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil import java.awt.BorderLayout import java.awt.Point import java.awt.Graphics @@ -397,6 +405,12 @@ class MessageView( wrap?.setReverting(active, text, onCancel) } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (role != SessionUiStyle.View.Message.USER_ROLE) return + wrap?.setQueued(active, onDelete) + } + private val promptToolbar: MessageToolbar? get() = wrap?.bar @@ -494,21 +508,26 @@ class MessageView( private inner class PromptWrap( private val box: JPanel, ) : JPanel(BorderLayout()), SessionCopyTarget { + private val footer = JPanel(BorderLayout()).also { it.isOpaque = false } val bar = MessageToolbar( { prompt?.copyMarkdown(trim = false) }, revert?.let { fn -> { fn(msg.info.id) } }, ) private val placeholder = bar.placeholder() - private var progress: RevertProgress? = null private var reverting = false + private var progress: RevertProgress? = null + private var queuedRow: JPanel? = null + private var queued = false override val copyAnchor: JComponent get() = placeholder - override val copyToolbar: JComponent? get() = if (reverting) null else bar + override val copyToolbar: JComponent? get() = if (reverting || queued) null else bar init { isOpaque = false add(box, BorderLayout.CENTER) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + footer.border = JBUI.Borders.emptyTop(UiStyle.Gap.xs()) + footer.add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.CENTER) + add(footer, BorderLayout.SOUTH) } override fun copyText(): String? = prompt?.copyMarkdown(trim = false) @@ -523,19 +542,54 @@ class MessageView( node.setText(text) if (reverting) return reverting = true - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(node.align(HAlign.LEFT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(node.align(HAlign.LEFT, VAlign.TOP)) revalidate() repaint() return } if (!reverting) return reverting = false - remove((layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)) - add(placeholder.align(HAlign.RIGHT, VAlign.TOP), BorderLayout.SOUTH) + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) revalidate() repaint() } + + @RequiresEdt + fun setQueued(active: Boolean, onDelete: () -> Unit) { + if (active) { + val node = queuedRow ?: queue(onDelete).also { queuedRow = it } + if (queued) return + queued = true + swapFooter(node.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + return + } + if (!queued) return + queued = false + swapFooter(placeholder.align(HAlign.RIGHT, VAlign.TOP)) + revalidate() + repaint() + } + + private fun swapFooter(node: JComponent) { + footer.removeAll() + footer.add(node, BorderLayout.CENTER) + } + + private fun queue(onDelete: () -> Unit) = Stack.horizontal(UiStyle.Gap.sm()).also { row -> + row.isOpaque = false + row.next(JBLabel(KiloBundle.message("session.queued")).apply { + foreground = UIUtil.getContextHelpForeground() + }) + row.next(toolbarButton( + ToolbarButtonAction( + AllIcons.Actions.Close, + KiloBundle.message("session.queued.remove"), + onDelete, + ), + )) + } } private fun assistantBorder() = JBUI.Borders.empty() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt index b4d8684004..8723fc4ce5 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -36,6 +36,7 @@ class TurnView( private val repo: String? = null, private val hover: ((PartView, Boolean) -> Unit)? = null, private val revert: ((String) -> Unit)? = null, + private val deleteQueued: ((String) -> Unit)? = null, ) : SessionLayoutPanel(SessionUiStyle.SessionLayout.GAP), Disposable, SessionEditorStyleTarget, SessionView { private val messages = LinkedHashMap() @@ -71,6 +72,12 @@ class TurnView( return view } + @RequiresEdt + fun setQueued(active: Boolean, onDelete: (String) -> Unit) { + val anchor = messages.values.firstOrNull { it.role == SessionUiStyle.View.Message.USER_ROLE } ?: return + anchor.setQueued(active) { onDelete(id) } + } + /** Remove the [MessageView] for [msgId] if present. */ fun removeMessage(msgId: String) { removeMessageChanged(msgId) diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index 2557da487f..6f26bb566d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -31,6 +31,8 @@ session.copy.hover=Copy session.copy.prompt=Copy prompt session.copy.response=Copy response session.copy.copied=Copied +session.queued=Queued +session.queued.remove=Remove queued message session.drop.files.title=Drop files here session.drop.files.subtitle=to add them to the prompt session.file.missing=Couldn''t find ''{0}'' in this repository. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index f439e04158..999dcb7c54 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -23,6 +23,8 @@ session.copy.hover=نسخ session.copy.prompt=نسخ الموجه session.copy.response=نسخ الرد session.copy.copied=تم النسخ +session.queued=في قائمة الانتظار +session.queued.remove=إزالة الرسالة من قائمة الانتظار session.tab.new=جلسة جديدة session.tab.untitled=جلسة بدون عنوان diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 3c3fe1f71a..fb61208a23 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiraj session.copy.prompt=Kopiraj prompt session.copy.response=Kopiraj odgovor session.copy.copied=Kopirano +session.queued=U redu čekanja +session.queued.remove=Ukloni poruku iz reda čekanja session.tab.new=Nova sesija session.tab.untitled=Sesija bez naslova diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 8d0d26a722..a785788ba0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiér session.copy.prompt=Kopiér prompt session.copy.response=Kopiér svar session.copy.copied=Kopieret +session.queued=I kø +session.queued.remove=Fjern besked fra køen session.tab.new=Ny session session.tab.untitled=Unavngivet session diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index 181ac9f165..837e4d8dd7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopieren session.copy.prompt=Prompt kopieren session.copy.response=Antwort kopieren session.copy.copied=Kopiert +session.queued=In Warteschlange +session.queued.remove=Nachricht aus Warteschlange entfernen session.tab.new=Neue Sitzung session.tab.untitled=Unbenannte Sitzung diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index fb67a0de55..25279f3492 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar respuesta session.copy.copied=Copiado +session.queued=En cola +session.queued.remove=Eliminar mensaje en cola session.tab.new=Nueva sesión session.tab.untitled=Sesión sin título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index 3848ae7ee9..3c279709d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -23,6 +23,8 @@ session.copy.hover=Copier session.copy.prompt=Copier le prompt session.copy.response=Copier la réponse session.copy.copied=Copié +session.queued=En attente +session.queued.remove=Supprimer le message en attente session.tab.new=Nouvelle session session.tab.untitled=Session sans titre diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 8c9e3d12c0..9d853d2a79 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -23,6 +23,8 @@ session.copy.hover=コピー session.copy.prompt=プロンプトをコピー session.copy.response=応答をコピー session.copy.copied=コピーしました +session.queued=キュー済み +session.queued.remove=キュー済みメッセージを削除 session.tab.new=新しいセッション session.tab.untitled=名前なしのセッション diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index b463a9b195..9b1797052c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -23,6 +23,8 @@ session.copy.hover=복사 session.copy.prompt=프롬프트 복사 session.copy.response=응답 복사 session.copy.copied=복사됨 +session.queued=대기 중 +session.queued.remove=대기 중인 메시지 제거 session.tab.new=새 세션 session.tab.untitled=제목 없는 세션 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index b29e1b9dc2..85fc3f6b31 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiëren session.copy.prompt=Prompt kopiëren session.copy.response=Antwoord kopiëren session.copy.copied=Gekopieerd +session.queued=In wachtrij +session.queued.remove=Bericht uit wachtrij verwijderen session.tab.new=Nieuwe sessie session.tab.untitled=Naamloze sessie diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index c577c42052..c160fdf400 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopier session.copy.prompt=Kopier prompt session.copy.response=Kopier svar session.copy.copied=Kopiert +session.queued=I kø +session.queued.remove=Fjern melding fra køen session.tab.new=Ny økt session.tab.untitled=Uten tittel diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index d57e58ac71..db9776e10b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopiuj session.copy.prompt=Kopiuj prompt session.copy.response=Kopiuj odpowiedź session.copy.copied=Skopiowano +session.queued=W kolejce +session.queued.remove=Usuń wiadomość z kolejki session.tab.new=Nowa sesja session.tab.untitled=Sesja bez tytułu diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 6655ce84b0..35dda74bfe 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -23,6 +23,8 @@ session.copy.hover=Copiar session.copy.prompt=Copiar prompt session.copy.response=Copiar resposta session.copy.copied=Copiado +session.queued=Na fila +session.queued.remove=Remover mensagem da fila session.tab.new=Nova sessão session.tab.untitled=Sessão sem título diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index c1eaee006a..3d53c6262d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -23,6 +23,8 @@ session.copy.hover=Копировать session.copy.prompt=Скопировать промпт session.copy.response=Скопировать ответ session.copy.copied=Скопировано +session.queued=В очереди +session.queued.remove=Удалить сообщение из очереди session.tab.new=Новая сессия session.tab.untitled=Незаголовок сессия diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 89e6d9a8df..3b400b23d0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -23,6 +23,8 @@ session.copy.hover=คัดลอก session.copy.prompt=คัดลอกพรอมต์ session.copy.response=คัดลอกคำตอบ session.copy.copied=คัดลอกแล้ว +session.queued=อยู่ในคิว +session.queued.remove=ลบข้อความในคิว session.tab.new=เซสชันใหม่ session.tab.untitled=เซสชันไม่มีชื่อ diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index d6ee1e84d9..8b7a2de3cd 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -23,6 +23,8 @@ session.copy.hover=Kopyala session.copy.prompt=Promptu kopyala session.copy.response=Yanıtı kopyala session.copy.copied=Kopyalandı +session.queued=Kuyrukta +session.queued.remove=Kuyruktaki mesajı kaldır session.tab.new=Yeni oturum session.tab.untitled=Başlıksız oturum diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index f385d236f4..896ee37e7a 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -23,6 +23,8 @@ session.copy.hover=Копіювати session.copy.prompt=Скопіювати промпт session.copy.response=Скопіювати відповідь session.copy.copied=Скопійовано +session.queued=У черзі +session.queued.remove=Видалити повідомлення з черги session.tab.new=Нова сесія session.tab.untitled=Сесія без назви diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 3f4b3fbc3d..864bce7a28 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -23,6 +23,8 @@ session.copy.hover=复制 session.copy.prompt=复制提示词 session.copy.response=复制回复 session.copy.copied=已复制 +session.queued=已排队 +session.queued.remove=移除排队消息 session.tab.new=新建会话 session.tab.untitled=无标题会话 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index dd2898d09a..48c5418ae7 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -23,6 +23,8 @@ session.copy.hover=複製 session.copy.prompt=複製提示詞 session.copy.response=複製回覆 session.copy.copied=已複製 +session.queued=已排入佇列 +session.queued.remove=移除佇列中的訊息 session.tab.new=新建工作階段 session.tab.untitled=未命名的工作階段 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt index b84cdb5da6..9d5e4225e1 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/PromptLifecycleTest.kt @@ -102,6 +102,46 @@ class PromptLifecycleTest : SessionControllerTestBase() { assertFalse(message.properties.containsValue("git-changes")) } + fun `test session queue changed updates queued set`() { + val (c, _, modelEvents) = prompted() + + emit(ChatEventDto.SessionQueueChanged("ses_test", listOf("u2"))) + + assertEquals(setOf("u2"), c.model.queued) + assertModelEvents( + """ + QueueChanged [u2] + """, + modelEvents, + ) + + emit(ChatEventDto.SessionQueueChanged("ses_test", emptyList())) + + assertEquals(emptySet(), c.model.queued) + } + + fun `test delete queued message delegates to RPC`() { + val (c, _, _) = prompted() + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertTrue(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + } + + fun `test delete queued message miss captures error`() { + val (c, _, _) = prompted() + rpc.messageDeleteResult = false + + edt { c.deleteQueuedMessage("u2") } + flush() + + assertEquals(listOf(ai.kilocode.client.testing.FakeSessionRpcApi.MessageDeleteCall("ses_test", "/test", "u2")), rpc.messageDeletes) + assertFalse(appRpc.telemetry.any { it.event == "Conversation Queued Message Removed" }) + assertTrue(appRpc.telemetry.any { it.event == "Session Error" && it.properties["context"] == "delete-message" }) + } + fun `test PermissionAsked moves state to AwaitingPermission`() { val (m, _, _) = prompted() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt index 70a6dc52bb..745062e15f 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/PromptPanelTest.kt @@ -1056,7 +1056,7 @@ class PromptPanelTest : BasePlatformTestCase() { assertTrue(resource("/icons/send_dark.svg").contains("fill=\"#0A7BD8\"")) } - fun `test busy disables send button`() { + fun `test busy allows sending draft`() { val panel = PromptPanel(project = project, onSend = { _, _ -> }, onAbort = {}, onEnhance = { _, _ -> }) panel.setReady(true) ApplicationManager.getApplication().invokeAndWait { panel.setText("hello") } @@ -1065,8 +1065,9 @@ class PromptPanelTest : BasePlatformTestCase() { panel.setBusy(true) - assertFalse(panel.isSendEnabled) + assertTrue(panel.isSendEnabled) assertTrue(panel.isStopEnabled) + assertNotSame(AllIcons.Actions.Suspend, panel.buttonForTest().icon) } fun `test auto approve button toggles and updates tooltip`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index d5e72cfc08..b27219697a 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -28,6 +28,7 @@ import ai.kilocode.client.session.views.tool.TaskToolView import ai.kilocode.client.session.views.tool.ToolView import ai.kilocode.client.session.views.todo.TodoWriteView import ai.kilocode.client.ui.DiffStatBadge +import ai.kilocode.client.ui.HoverIcon import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.DiffFileDto import ai.kilocode.rpc.dto.MessageDto @@ -129,6 +130,32 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { ) } + fun `test queued turn shows badge and remove action`() { + var deleted: String? = null + Disposer.dispose(parent) + parent = Disposer.newDisposable("test-queued") + model = SessionModel() + panel = SessionMessageListPanel(model, parent, openFile = openFile, deleteQueued = { deleted = it }) + model.upsertMessage(msg("u1", "user")) + model.updateContent("u1", part("p1", "u1", "text", text = "first")) + model.upsertMessage(msg("u2", "user")) + model.updateContent("u2", part("p2", "u2", "text", text = "second")) + + model.setQueued(setOf("u2")) + + val u1 = panel.findMessage("u1")!! + val u2 = panel.findMessage("u2")!! + assertFalse(components(u1).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + assertTrue(components(u2).filterIsInstance().any { it.text == KiloBundle.message("session.queued") }) + + val remove = components(u2).filterIsInstance().single() + assertEquals(KiloBundle.message("session.queued.remove"), remove.toolTipText) + assertEquals(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR), remove.cursor) + remove.doClick() + + assertEquals("u2", deleted) + } + // ------ TurnAdded ------ fun `test user message creates turn and is findable by message id`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index cb14e09f07..65f282db4e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -96,6 +96,8 @@ class FakeSessionRpcApi : KiloSessionRpcApi { val aborts = mutableListOf>() val compacts = mutableListOf>() val reverts = mutableListOf() + val messageDeletes = mutableListOf() + var messageDeleteResult = true val unreverts = mutableListOf>() val configs = mutableListOf>() val permissionReplies = mutableListOf>() @@ -117,6 +119,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { data class AttachmentCall(val id: String, val directory: String, val messageId: String, val partId: String, val attachmentKey: String?) data class CommandCall(val id: String, val directory: String, val command: String, val arguments: String, val prompt: PromptDto) data class RevertCall(val id: String, val directory: String, val message: String, val part: String?) + data class MessageDeleteCall(val id: String, val directory: String, val message: String) // --- Implementation --- @@ -229,6 +232,12 @@ class FakeSessionRpcApi : KiloSessionRpcApi { reverts.add(RevertCall(id, directory, messageID, partID)) } + override suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean { + assertNotEdt("deleteMessage") + messageDeletes.add(MessageDeleteCall(id, directory, messageID)) + return messageDeleteResult + } + override suspend fun unrevert(id: String, directory: String) { assertNotEdt("unrevert") unrevertGate?.await() diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index 7b80c74995..9e9e7cd62d 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.11 +kilo.jetbrains.version=7.0.12-rc.2 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt index 4f8e06f169..588c0c9d0e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -33,6 +33,7 @@ object ChatLogSummary { is ChatEventDto.SessionStatusChanged -> event.sessionID is ChatEventDto.SessionUpdated -> event.sessionID is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionQueueChanged -> event.sessionID is ChatEventDto.SessionCompacted -> event.sessionID is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID @@ -211,6 +212,12 @@ object ChatLogSummary { "evt=session.idle", ) + is ChatEventDto.SessionQueueChanged -> join( + sid(event.sessionID), + "evt=session.queue.changed", + "queued=${event.queued.size}", + ) + is ChatEventDto.SessionCompacted -> join( sid(event.sessionID), "evt=session.compacted", diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt index 6b6cfa4f97..0fab2e8696 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloSessionRpcApi.kt @@ -90,6 +90,9 @@ interface KiloSessionRpcApi : RemoteApi { /** Revert a session to a prior user message or part. */ suspend fun revert(id: String, directory: String, messageID: String, partID: String?) + /** Delete a single message (used to remove a queued prompt). */ + suspend fun deleteMessage(id: String, directory: String, messageID: String): Boolean + /** Redo all reverted changes for a session. */ suspend fun unrevert(id: String, directory: String) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index dbb053f8fd..edbbed7b02 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -254,6 +254,13 @@ sealed class ChatEventDto { val sessionID: String, ) : ChatEventDto() + @Serializable + @SerialName("session.queue.changed") + data class SessionQueueChanged( + val sessionID: String, + val queued: List = emptyList(), + ) : ChatEventDto() + @Serializable @SerialName("session.compacted") data class SessionCompacted( diff --git a/packages/kilo-ui/src/components/code.tsx b/packages/kilo-ui/src/components/code.tsx index c5ff061f33..cbf22e2d4c 100644 --- a/packages/kilo-ui/src/components/code.tsx +++ b/packages/kilo-ui/src/components/code.tsx @@ -19,7 +19,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - fileGap: 0, + spacing: 0, } satisfies Partial const codeStyle = { diff --git a/packages/kilo-vscode/bunfig.toml b/packages/kilo-vscode/bunfig.toml index 2c6410f31e..0ae571971a 100644 --- a/packages/kilo-vscode/bunfig.toml +++ b/packages/kilo-vscode/bunfig.toml @@ -1,2 +1,2 @@ [test] -preload = ["./tests/setup/vscode-mock.ts"] +preload = ["./tests/setup/vscode-mock.ts", "./tests/setup/worker-url.ts"] diff --git a/packages/kilo-vscode/esbuild.js b/packages/kilo-vscode/esbuild.js index db068238ed..eed8e2475b 100644 --- a/packages/kilo-vscode/esbuild.js +++ b/packages/kilo-vscode/esbuild.js @@ -77,6 +77,26 @@ const pierreWorkerAliasPlugin = { }, } +/** + * Replace Markdown's Vite-only worker URL import with the URI injected by the + * extension host. The worker itself is emitted as a separate dist asset below. + * + * @type {import('esbuild').Plugin} + */ +const markdownWorkerUrlPlugin = { + name: "markdown-worker-url", + setup(build) { + build.onResolve({ filter: /markdown-shiki\.worker\.ts\?worker&url$/ }, () => ({ + path: "markdown-shiki-worker-url", + namespace: "kilo-worker-url", + })) + build.onLoad({ filter: /.*/, namespace: "kilo-worker-url" }, () => ({ + contents: "export default window.KILO_MARKDOWN_SHIKI_WORKER_URI", + loader: "js", + })) + }, +} + /** * Resolve the synthetic `kilo-shiki-worker` entry point to Pierre's Shiki worker * so esbuild can bundle it (and its inlined oniguruma WebAssembly) into a single @@ -159,6 +179,7 @@ function createBrowserWebviewContext(entryPoint, outfile) { plugins: [ solidDedupePlugin, pierreWorkerAliasPlugin, + markdownWorkerUrlPlugin, svgSpritePlugin, cssPackageResolvePlugin, solidPlugin(), @@ -184,6 +205,21 @@ function createShikiWorkerContext() { }) } +function createMarkdownShikiWorkerContext() { + return esbuild.context({ + entryPoints: [path.join(__dirname, "..", "ui", "src", "components", "markdown-shiki.worker.ts")], + bundle: true, + format: "esm", + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: "browser", + outfile: "dist/markdown-shiki-worker.js", + logLevel: "silent", + plugins: [esbuildProblemMatcherPlugin], + }) +} + async function main() { // Build extension const extensionCtx = await esbuild.context({ @@ -229,6 +265,7 @@ async function main() { // Build the shared Shiki highlighting worker asset const shikiWorkerCtx = await createShikiWorkerContext() + const markdownShikiWorkerCtx = await createMarkdownShikiWorkerContext() if (watch) { await Promise.all([ @@ -240,6 +277,7 @@ async function main() { kiloClawCtx.watch(), marketplaceCtx.watch(), shikiWorkerCtx.watch(), + markdownShikiWorkerCtx.watch(), ]) } else { await Promise.all([ @@ -251,6 +289,7 @@ async function main() { diffViewerCtx.rebuild(), diffVirtualCtx.rebuild(), shikiWorkerCtx.rebuild(), + markdownShikiWorkerCtx.rebuild(), ]) await Promise.all([ extensionCtx.dispose(), @@ -261,6 +300,7 @@ async function main() { kiloClawCtx.dispose(), marketplaceCtx.dispose(), shikiWorkerCtx.dispose(), + markdownShikiWorkerCtx.dispose(), ]) } } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index a4101b7a63..5e366f615d 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1190,6 +1190,7 @@ export class AgentManagerProvider implements Disposable { this.registerWorktreeSession(session.id, wt.result.path) this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id) + this.panel?.sessions.registerSession(session) // Set the per-version model immediately so the UI selector reflects // the correct model as soon as the worktree appears, before Phase 2. diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 87ff464e25..fba6bb74b7 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -45,6 +45,7 @@ export function buildWebviewHtml( ): string { const nonce = getNonce() const csp = buildCspString(webview.cspSource, nonce, opts.port) + const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js") return ` @@ -82,7 +83,7 @@ export function buildWebviewHtml(
- + ` diff --git a/packages/kilo-vscode/tests/markdown-mermaid.spec.ts b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts new file mode 100644 index 0000000000..e18acc7e33 --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test" + +test("renders Mermaid from delimiter-free Markdown source", async ({ page }) => { + await page.goto("/iframe.html?id=shared--markdown-mermaid&viewMode=story") + + const markdown = page.locator('[data-component="markdown"]') + await expect(markdown.getByRole("heading", { name: "Flow" })).toBeVisible() + const diagram = markdown.locator('[data-mermaid-state="rendered"]') + await expect(diagram.locator('svg[aria-roledescription="flowchart-v2"]')).toBeVisible() + + const source = diagram.locator('code[data-lang="mermaid"]') + await expect(source).toContainText("flowchart TD") + await expect(source).not.toContainText("```mermaid") + await expect(markdown.getByText("Rendered after the diagram.")).toBeVisible() +}) diff --git a/packages/kilo-vscode/tests/setup/worker-url.ts b/packages/kilo-vscode/tests/setup/worker-url.ts new file mode 100644 index 0000000000..85b0b76adf --- /dev/null +++ b/packages/kilo-vscode/tests/setup/worker-url.ts @@ -0,0 +1,11 @@ +import { plugin } from "bun" + +plugin({ + name: "worker-url", + setup(build) { + build.onLoad({ filter: /\?worker&url$/ }, () => ({ + contents: "export default 'test-worker.js'", + loader: "js", + })) + }, +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 3f2d8fcb6f..c6201c3df5 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -548,6 +548,19 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("removeWorktree") }) + it("multi-version creation registers each session after publishing its worktree mapping", () => { + const text = body("onCreateMultiVersion") + const ready = text.indexOf("this.notifyWorktreeReady(session.id, wt.result, wt.worktree.id)") + const register = text.indexOf("this.panel?.sessions.registerSession(session)") + const initial = text.indexOf("agentManager.sendInitialMessage") + + expect(ready, "multi-version path must publish ready state").toBeGreaterThan(-1) + expect(register, "multi-version path must register the created session").toBeGreaterThan(-1) + expect(register, "sessionCreated must follow the worktree mapping").toBeGreaterThan(ready) + expect(initial, "initial prompt phase must exist").toBeGreaterThan(-1) + expect(register, "session must be registered before the initial prompt").toBeLessThan(initial) + }) + // -- onPromoteSession invariants ------------------------------------------- /** diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 7c226ecaba..3cef7f0679 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -19,6 +19,7 @@ import path from "node:path" const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..") const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui") +const WORKER_URL = path.join(MONOREPO_ROOT, "packages/kilo-vscode/tests/setup/worker-url.ts") const BASIC_TOOL_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/basic-tool.tsx") const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data.tsx") const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") @@ -44,7 +45,7 @@ const TRANSCRIPT_PARTS_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/web const CHAT_LAYOUT_FILE = path.join(MONOREPO_ROOT, "packages/kilo-vscode/webview-ui/src/styles/chat-layout.css") function check(code: string): { ok: boolean; output: string } { - const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", code], { + const result = Bun.spawnSync(["bun", "--preload", WORKER_URL, "--conditions=browser", "-e", code], { cwd: KILO_UI_DIR, stdout: "pipe", stderr: "pipe", diff --git a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts b/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts deleted file mode 100644 index 8a582935ab..0000000000 --- a/packages/kilo-vscode/tests/unit/markdown-raf-coalesce.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { readFileSync } from "node:fs" -import { join } from "node:path" - -/** - * Regression guard for the markdown rAF-coalesced parse fix. - * - * PROBLEM: - * The `Markdown` component's render effect did `temp.innerHTML = content` - * + `morphdom(...)` on every update. During LLM token streaming, this - * fired 60–200 times per second, reparsing the entire accumulated HTML - * every time. CPU profile of a 7s streaming window showed 2,940 ParseHTML - * events (~619ms, ~46% of blocked main-thread time). - * - * FIX: - * Queue the latest content in a component-scoped variable and run the - * morphdom pass inside a requestAnimationFrame callback. Further updates - * before the frame fires simply overwrite the pending content — K rapid - * token updates collapse to 1 parse. The onCleanup handler cancels any - * queued frame so it doesn't touch the unmounted DOM. - * - * For the matching runtime assertion, see - * `tests/webview-reactivity/markdown-parse-rate.test.ts`. - */ -describe("Markdown rAF-coalesced parse — regression guard", () => { - const path = join(__dirname, "..", "..", "..", "ui", "src", "components", "markdown.tsx") - const helper = join(__dirname, "..", "..", "..", "ui", "src", "kilocode", "markdown-stream-highlight.ts") - - const stripComments = (src: string): string => - src - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\{\/\*[\s\S]*?\*\/\}/g, "") - .replace(/^\s*\/\/.*$/gm, "") - - const src = stripComments(readFileSync(path, "utf8")) - const body = stripComments(readFileSync(helper, "utf8")) - - it("render effect uses requestAnimationFrame to coalesce parses", () => { - // Locate the createEffect that owns the morphdom call. - const match = src.match(/createEffect\s*\(\s*\(\s*\)\s*=>\s*\{[\s\S]*?morphdom\s*\([\s\S]*?^\s*\}\s*\)/m) - expect(match, "render createEffect must contain a morphdom call").toBeTruthy() - const body = match![0] - expect(body).toMatch(/requestAnimationFrame/) - }) - - it("cleans up the queued frame on dispose", () => { - expect(src).toMatch(/cancelAnimationFrame/) - }) - - it("exposes a pending frame/content state scoped to the component", () => { - // Any of these forms count. We just need the state to exist so that - // rapid updates can collapse into it. - expect(src).toMatch(/\b(pendingFrame|pendingContent)\b/) - }) - - it("delegates streamed Shiki refreshes to the Kilo-owned helper", () => { - expect(src).toContain("preserveStreamingHighlight(fromEl, toEl, local.streaming ?? false)") - expect(body).toContain("export function preserveStreamingHighlight") - expect(body).toMatch(/continues\(before, after\)[\s\S]*queue\(from, after, lang\)/) - expect(body).toMatch(/const done = \(\) => \{\s*job\.busy = false\s*if \(!pre\.isConnected\) return/) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts index 8d0b9abccc..c94054a5d5 100644 --- a/packages/kilo-vscode/tests/unit/transcript-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-parts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test" import path from "node:path" const WEBVIEW = path.resolve(import.meta.dir, "../../webview-ui") +const WORKER_URL = path.resolve(import.meta.dir, "../setup/worker-url.ts") const PASS = "TRANSCRIPT_PARTS_PASS" const FAIL = "TRANSCRIPT_PARTS_FAIL:" @@ -61,7 +62,7 @@ const SCRIPT = ` describe("transcript parts", () => { it("keeps timeline candidates aligned with visible transcript parts", () => { - const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", SCRIPT], { + const result = Bun.spawnSync(["bun", "--preload", WORKER_URL, "--conditions=browser", "-e", SCRIPT], { cwd: WEBVIEW, stdout: "pipe", stderr: "pipe", diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index bf3a16f7dc..81b27047db 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -10,6 +10,7 @@ import { ModelSelectorBase } from "../components/shared/ModelSelector" import { SessionContext } from "../context/session" import type { EnrichedModel } from "../context/provider" import type { ModelSelection } from "../types/messages" +import { Markdown } from "@kilocode/kilo-ui/markdown" const meta: Meta = { title: "Shared", @@ -18,6 +19,27 @@ const meta: Meta = { export default meta type Story = StoryObj +export const MarkdownMermaid: Story = { + name: "Markdown - Mermaid diagram", + render: () => ( + + B{Needs tools?} + B -->|Yes| C[Run tool] + B -->|No| D[Respond] + C --> D +\`\`\` + +Rendered after the diagram.`} + /> + + ), +} + // --------------------------------------------------------------------------- // ModelSelector // --------------------------------------------------------------------------- diff --git a/packages/llm/package.json b/packages/llm/package.json index d2630bebd3..892d40dce2 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -8,8 +8,8 @@ "scripts": { "setup:recording-env": "bun run script/setup-recording-env.ts", "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.ts", diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 42dcaef540..80412ca9dd 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -412,6 +412,9 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: stopSequences: generation?.stop, }, toolConfig, + // Converse's base inferenceConfig has no topK; Anthropic/Nova accept it + // as a model-specific field, so it goes through additionalModelRequestFields. + additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK }, } }) diff --git a/packages/llm/sst-env.d.ts b/packages/llm/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/llm/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index 23483dda25..46657331a3 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -83,6 +83,26 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("passes topK through additionalModelRequestFields as top_k", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.updateRequest(baseRequest, { generation: { maxTokens: 64, temperature: 0, topK: 40 } }), + ) + + // Converse's inferenceConfig has no topK; Anthropic/Nova read it from + // additionalModelRequestFields as top_k. + expect(prepared.body.inferenceConfig).toEqual({ maxTokens: 64, temperature: 0 }) + expect(prepared.body.additionalModelRequestFields).toEqual({ top_k: 40 }) + }), + ) + + it.effect("omits additionalModelRequestFields when topK is unset", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare(baseRequest) + expect(prepared.body.additionalModelRequestFields).toBeUndefined() + }), + ) + it.effect("lowers chronological system updates to wrapped user text in order", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( diff --git a/packages/opencode/script/kilocode/test-profile.ts b/packages/opencode/script/kilocode/test-profile.ts index 5e3e222636..9dbe5d3683 100644 --- a/packages/opencode/script/kilocode/test-profile.ts +++ b/packages/opencode/script/kilocode/test-profile.ts @@ -16,7 +16,7 @@ export namespace TestProfile { "kilocode/{external-directory-boundary,read-directory}.test.ts", "util/filesystem.test.ts", ], - pty: ["pty/*.test.ts", "server/httpapi-pty.test.ts"], + pty: ["server/httpapi-pty.test.ts", "server/httpapi-v2-pty.test.ts"], runtime: [ "cli/serve/*.test.ts", "kilocode/background-process.test.ts", diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index d4ad056715..53e3b8bb7a 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -80,10 +80,11 @@ export class Subscription { async replayMessage(message: SessionMessageResponse) { if (message.info.role !== "assistant" && message.info.role !== "user") return + const cwd = message.info.role === "assistant" ? message.info.path?.cwd : undefined for (const part of message.parts) { await this.recordFetchedPart(message.info.sessionID, message, part) if (part.type === "tool") { - await this.handleToolPart(message.info.sessionID, part) + await this.handleToolPart(message.info.sessionID, part, cwd ?? process.cwd()) continue } await this.replayContentPart(message, part) @@ -146,7 +147,7 @@ export class Subscription { }), ) if (part.type === "tool") { - await this.handleToolPart(session.id, part) + await this.handleToolPart(session.id, part, session.cwd) } } @@ -231,8 +232,8 @@ export class Subscription { ) } - private async handleToolPart(sessionId: string, part: ToolPart) { - await this.toolStart(sessionId, part) + private async handleToolPart(sessionId: string, part: ToolPart, cwd: string) { + await this.toolStart(sessionId, part, cwd) switch (part.state.status) { case "pending": @@ -240,7 +241,7 @@ export class Subscription { return case "running": - await this.runningTool(sessionId, part) + await this.runningTool(sessionId, part, cwd) return case "completed": @@ -253,6 +254,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -268,6 +270,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -275,7 +278,7 @@ export class Subscription { } } - private async runningTool(sessionId: string, part: ToolPart) { + private async runningTool(sessionId: string, part: ToolPart, cwd: string) { if (part.state.status !== "running") return const output = part.tool === "bash" ? shellOutputSnapshot(part.state) : undefined @@ -289,6 +292,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) @@ -306,12 +310,13 @@ export class Subscription { toolName: part.tool, state: part.state, output, + cwd, }), }, }) } - private async toolStart(sessionId: string, part: ToolPart) { + private async toolStart(sessionId: string, part: ToolPart, cwd: string) { if (this.toolStarts.has(part.callID)) return this.toolStarts.add(part.callID) await this.input.connection.sessionUpdate({ @@ -322,6 +327,7 @@ export class Subscription { toolCallId: part.callID, toolName: part.tool, state: part.state, + cwd, }), }, }) diff --git a/packages/opencode/src/acp/tool.ts b/packages/opencode/src/acp/tool.ts index 4c39c0eff1..d0e57cc2ec 100644 --- a/packages/opencode/src/acp/tool.ts +++ b/packages/opencode/src/acp/tool.ts @@ -1,3 +1,4 @@ +import { isAbsolute, resolve } from "path" import type { ToolCall, ToolCallContent, ToolCallLocation, ToolCallUpdate, ToolKind } from "@agentclientprotocol/sdk" export type ToolInput = Record @@ -69,10 +70,16 @@ export function toToolKind(toolName: string): ToolKind { } } -export function toLocations(toolName: string, input: ToolInput): ToolCallLocation[] { +export function toLocations(toolName: string, input: ToolInput, cwd?: string): ToolCallLocation[] { const tool = toolName.toLocaleLowerCase() switch (tool) { + case "bash": + case "shell": { + const workdir = shellWorkdir(input, cwd) + return workdir ? [{ path: workdir }] : [] + } + case "read": case "edit": case "write": @@ -88,10 +95,6 @@ export function toLocations(toolName: string, input: ToolInput): ToolCallLocatio case "context7_get_library_docs": return locationFrom(input.path) - case "bash": - case "shell": - return [] - default: return [] } @@ -122,14 +125,15 @@ export function pendingToolCall(input: { readonly toolCallId: string readonly toolName: string readonly state: { readonly input: ToolInput; readonly title?: string } + readonly cwd?: string }): ToolCall { return { toolCallId: input.toolCallId, - title: input.state.title || input.toolName, + title: toolTitle(input.toolName, input.state.input, input.state.title), kind: toToolKind(input.toolName), status: "pending", - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), } } @@ -138,6 +142,7 @@ export function runningToolUpdate(input: { readonly toolName: string readonly state: RunningToolState readonly output?: string + readonly cwd?: string }): ToolCallUpdate { const content = input.output ? [ @@ -155,9 +160,9 @@ export function runningToolUpdate(input: { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state.input, input.state.title), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), ...(content ? { content } : {}), } } @@ -166,29 +171,32 @@ export function duplicateRunningToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: RunningToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "in_progress", kind: toToolKind(input.toolName), - title: input.state.title ?? input.toolName, - locations: toLocations(input.toolName, input.state.input), - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state.input, input.state.title), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), } } export function completedToolUpdate(input: { readonly toolCallId: string readonly toolName: string - readonly state: CompletedToolState & { readonly title: string } + readonly state: CompletedToolState & { readonly title?: string } + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "completed", kind: toToolKind(input.toolName), - title: input.state.title, + title: toolTitle(input.toolName, input.state.input, input.state.title), + locations: toLocations(input.toolName, input.state.input, input.cwd), content: completedToolContent(input.toolName, input.state), - rawInput: input.state.input, + rawInput: rawInput(input.toolName, input.state.input, input.cwd), rawOutput: completedToolRawOutput(input.state), } } @@ -197,13 +205,15 @@ export function errorToolUpdate(input: { readonly toolCallId: string readonly toolName: string readonly state: ErrorToolState + readonly cwd?: string }): ToolCallUpdate { return { toolCallId: input.toolCallId, status: "failed", kind: toToolKind(input.toolName), - title: input.toolName, - rawInput: input.state.input, + title: toolTitle(input.toolName, input.state.input, undefined), + locations: toLocations(input.toolName, input.state.input, input.cwd), + rawInput: rawInput(input.toolName, input.state.input, input.cwd), content: [ { type: "content", @@ -253,6 +263,42 @@ export function shellOutputSnapshot(state: { readonly metadata?: unknown }) { return stringValue((state.metadata as Record).output) } +// For shell tools, surface the actual command as the title so it stays visible +// before output lands; non-shell tools keep their model-provided title. +function toolTitle(toolName: string, input: ToolInput, fallback: string | undefined) { + if (isShell(toolName)) return shellCommand(input) ?? stringValue(input.description) ?? fallback ?? toolName + return fallback || toolName +} + +// Enrich shell rawInput with the resolved working directory so clients can show +// where the command runs, unless the model already specified one. +function rawInput(toolName: string, input: ToolInput, cwd?: string): ToolInput { + if (!isShell(toolName)) return input + if (input.cwd || input.workdir) return input + const workdir = shellWorkdir(input, cwd) + return workdir ? { ...input, cwd: workdir } : input +} + +function shellWorkdir(input: ToolInput, cwd?: string) { + const explicit = stringValue(input.workdir) ?? stringValue(input.cwd) + return resolvePath(explicit, cwd) ?? cwd +} + +function resolvePath(value: string | undefined, cwd?: string) { + if (!value) return undefined + if (isAbsolute(value)) return value + return resolve(cwd ?? process.cwd(), value) +} + +function shellCommand(input: ToolInput) { + return stringValue(input.command) ?? stringValue(input.cmd) +} + +function isShell(toolName: string) { + const tool = toolName.toLocaleLowerCase() + return tool === "bash" || tool === "shell" +} + export const mapToolKind = toToolKind export const extractLocations = toLocations export const buildCompletedToolContent = completedToolContent diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 809c91559f..fb3fb492f7 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -5,6 +5,7 @@ import { Cause } from "effect" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { MCP } from "../../mcp" @@ -770,7 +771,7 @@ export const McpDebugCommand = effectCmd({ jsonrpc: "2.0", method: "initialize", params: { - protocolVersion: "2024-11-05", + protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "kilo-debug", version: InstallationVersion }, // kilocode_change }, diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index 7f7b41191b..e022911c95 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -6,7 +6,7 @@ import { Identifier } from "@/id/id" import { Instance, type InstanceContext } from "@/kilocode/instance" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { SessionID } from "@/session/schema" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { ProjectV2 } from "@opencode-ai/core/project" import { Process } from "@/util/process" import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" diff --git a/packages/opencode/src/kilocode/command-timeout.ts b/packages/opencode/src/kilocode/command-timeout.ts index 4e801af258..6be471bfb5 100644 --- a/packages/opencode/src/kilocode/command-timeout.ts +++ b/packages/opencode/src/kilocode/command-timeout.ts @@ -1,5 +1,5 @@ import { Process } from "@/util/process" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Effect, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner" diff --git a/packages/opencode/src/kilocode/interactive-terminal/index.ts b/packages/opencode/src/kilocode/interactive-terminal/index.ts index f02bc8ca17..7b5afca568 100644 --- a/packages/opencode/src/kilocode/interactive-terminal/index.ts +++ b/packages/opencode/src/kilocode/interactive-terminal/index.ts @@ -6,7 +6,7 @@ import { appendTerminalOutput } from "@/kilocode/interactive-terminal/output" import { Identifier } from "@/id/id" import { Instance, type InstanceContext } from "@/kilocode/instance" import { SessionID } from "@/session/schema" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@opencode-ai/core/schema" import { zod, ZodOverride } from "@opencode-ai/core/effect-zod" import * as Log from "@opencode-ai/core/util/log" diff --git a/packages/opencode/src/kilocode/provider/provider.ts b/packages/opencode/src/kilocode/provider/provider.ts index 91aef28073..cc8a452fc9 100644 --- a/packages/opencode/src/kilocode/provider/provider.ts +++ b/packages/opencode/src/kilocode/provider/provider.ts @@ -11,6 +11,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { optionalOmitUndefined } from "@opencode-ai/core/schema" +import { ProviderError } from "@/provider/error" import { Effect, Schema } from "effect" import type { LanguageModelV3 } from "@ai-sdk/provider" import { mapValues, omit, pickBy } from "remeda" @@ -245,24 +246,37 @@ export function kiloSmallModelPriority(providerID: string): string[] | undefined } // --------------------------------------------------------------------------- -// Fetch timeout wrapper +// Fetch timeout wrappers // Replaces AbortSignal.timeout() with a cancellable setTimeout+AbortController -// so the timer is cleared once response headers arrive. This prevents healthy -// streaming responses from being aborted mid-stream. +// so the timer is cleared once response headers arrive, then hands the remaining +// deadline to wrapFirstByte until the body produces data. One configured +// `timeout` value bounds both phases together, so providers that accept a +// request and go silent cannot hang the agent loop, while healthy streaming +// responses are never aborted mid-stream. // --------------------------------------------------------------------------- +/** + * Resolves the configured request timeout in milliseconds. `timeout: false` + * explicitly disables it (returns `undefined`); any other invalid, unset or + * non-positive value falls back to {@link REQUEST_TIMEOUT_MS} so the wait for a + * provider response is always bounded rather than left open-ended. + */ +export function requestTimeout(options: Record): number | undefined { + const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS + if (ms === false) return undefined + if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return ms + return REQUEST_TIMEOUT_MS +} + export function buildTimeoutSignal(options: Record): { signal: AbortSignal | undefined clear: () => void } { - const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS - if (ms === false || ms === undefined || ms === null) return { signal: undefined, clear() {} } + const ms = requestTimeout(options) + if (ms === undefined) return { signal: undefined, clear() {} } const controller = new AbortController() - const timer = setTimeout( - () => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), - ms as number, - ) + const timer = setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms) return { signal: controller.signal, clear() { @@ -270,3 +284,67 @@ export function buildTimeoutSignal(options: Record): { }, } } + +/** + * Bounds the wait for the response body's first byte by `ms`. + * + * Response headers do not prove a live stream: a provider can answer 200 with + * SSE headers and then never send data. The connection-phase timeout is cleared + * as soon as headers arrive, so that state used to hang the agent loop forever + * (the turn sits between step-finish and the next step-start with no error). + * + * Only the first byte is guarded. Once any data arrives the wrapper becomes a + * passthrough, so idle gaps inside a streaming response (reasoning, buffering, + * slow token generation) are never touched here and remain opt-in through + * `chunkTimeout`. + */ +export function wrapFirstByte(res: Response, ms: number, ctl: AbortController) { + if (typeof ms !== "number" || ms <= 0) return res + if (!res.body) return res + + const reader = res.body.getReader() + let seen = false + const body = new ReadableStream({ + async pull(ctrl) { + if (seen) { + const part = await reader.read() + if (part.done) return ctrl.close() + return ctrl.enqueue(part.value) + } + + const part = await new Promise>>((resolve, reject) => { + const id = setTimeout(() => { + const err = new ProviderError.ResponseStreamError(`Provider sent no response data within ${ms}ms`) + ctl.abort(err) + void reader.cancel(err) + reject(err) + }, ms) + + reader.read().then( + (part) => { + clearTimeout(id) + resolve(part) + }, + (err) => { + clearTimeout(id) + reject(err) + }, + ) + }) + + seen = true + if (part.done) return ctrl.close() + ctrl.enqueue(part.value) + }, + async cancel(reason) { + ctl.abort(reason) + await reader.cancel(reason) + }, + }) + + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} diff --git a/packages/opencode/src/kilocode/pty/self-command.ts b/packages/opencode/src/kilocode/pty/self-command.ts index 360e094bda..25017d720d 100644 --- a/packages/opencode/src/kilocode/pty/self-command.ts +++ b/packages/opencode/src/kilocode/pty/self-command.ts @@ -1,61 +1 @@ -import path from "path" - -type Input = { - command?: string - args?: string[] - cwd?: string -} - -type Command = { - command: string - args: string[] - cwd?: string -} - -const names = new Set(["kilo", "kilocode"]) -const self = command() - -function clean(input: string[]) { - return input.filter((arg, index) => { - if (arg === "--cwd") return false - if (input[index - 1] === "--cwd") return false - if (arg.startsWith("--cwd=")) return false - return true - }) -} - -function full(input: string, cwd: string) { - if (path.isAbsolute(input)) return input - return path.resolve(cwd, input) -} - -export function command( - proc = { argv: process.argv, execArgv: process.execArgv, execPath: process.execPath, cwd: process.cwd() }, -): Command { - const script = proc.argv[1] - const bundled = script?.startsWith("/$bunfs/") || (script ? /^[A-Za-z]:[\\/]~BUN[\\/]/.test(script) : false) - if (script && !bundled && /\.(ts|js|mjs|cjs)$/.test(script)) { - const file = full(script, proc.cwd) - const dir = path.dirname(file) - const root = path.basename(dir) === "src" ? path.dirname(dir) : proc.cwd - return { command: full(proc.execPath, proc.cwd), args: [...clean(proc.execArgv), file], cwd: root } - } - return { command: full(proc.execPath, proc.cwd), args: [] } -} - -export function resolve(input: Input, cmd = self): Input { - if (!input.command || !names.has(input.command)) return input - const args = input.args ?? [] - const project = cmd.cwd && args.length === 0 && input.cwd ? [input.cwd] : [] - return { - ...input, - command: cmd.command, - args: [...cmd.args, ...project, ...args], - cwd: cmd.cwd ?? input.cwd, - } -} - -export const KiloPtySelfCommand = { - command, - resolve, -} +export { command, KiloPtySelfCommand, resolve } from "@opencode-ai/core/kilocode/pty-self-command" diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index 3a9a19a78e..5c1afeee40 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -1,6 +1,6 @@ import { Layer } from "effect" import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" -import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { compressionLayer } from "@/server/routes/instance/httpapi/middleware/compression" import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-vary" import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error" diff --git a/packages/opencode/src/kilocode/server/server.ts b/packages/opencode/src/kilocode/server/server.ts index cebc36481e..46fd88dd07 100644 --- a/packages/opencode/src/kilocode/server/server.ts +++ b/packages/opencode/src/kilocode/server/server.ts @@ -2,13 +2,5 @@ // Kilo-specific overrides for the server control plane. // Imported by ../../server/server.ts with minimal kilocode_change markers. -/** Additional CORS origin check for *.kilo.ai */ -export function corsOrigin(input: string): string | undefined { - if (/^https:\/\/([a-z0-9-]+\.)*kilo\.ai$/.test(input)) { - return input - } - return undefined -} - export const DOC_TITLE = "kilo" export const DOC_DESCRIPTION = "kilo api" diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index d951e934dc..551c1cd636 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -183,6 +183,7 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, + reserved: Effect.Effect = Effect.void, ): Effect.Effect { return Effect.acquireUseRelease( Effect.sync(() => { @@ -205,7 +206,8 @@ export namespace KiloSessionPromptQueue { return { seq: mine, version: version(sessionID), target, previous, done, tail } satisfies Slot }), (slot) => - Effect.promise(() => settle(slot.previous)).pipe( + reserved.pipe( + Effect.andThen(Effect.promise(() => settle(slot.previous))), Effect.flatMap(() => { if (isCancelled(sessionID, slot)) return cancelled // Snapshot the latest seq at the moment this slot actually starts diff --git a/packages/opencode/src/kilocode/shell/shell.ts b/packages/opencode/src/kilocode/shell/shell.ts index b134c43376..c27d814ada 100644 --- a/packages/opencode/src/kilocode/shell/shell.ts +++ b/packages/opencode/src/kilocode/shell/shell.ts @@ -1,124 +1 @@ -export function args(command: string) { - return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] -} - -const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); -[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); -$OutputEncoding = [Console]::OutputEncoding; -` - -function script(command: string) { - const pos = prologue(command) - const head = command.slice(0, pos) - const body = command.slice(pos) - const gap = head && !/[;\r\n]\s*$/.test(head) ? "\n" : "" - return `${head}${gap}${setup}${body}` -} - -function prologue(command: string) { - const pos = scan(command, 0) - const attr = attrs(command, pos) - const body = command.slice(attr) - const match = /^param\s*\(/i.exec(body) - if (!match) return pos - - const start = attr + match[0].lastIndexOf("(") - const end = block(command, start, "(", ")") - if (end === undefined) return pos - return end -} - -function attrs(command: string, start: number) { - let pos = start - while (pos < command.length) { - const next = scan(command, pos) - if (command[next] !== "[") return next - const end = block(command, next, "[", "]") - if (end === undefined) return start - pos = end - } - return pos -} - -function scan(command: string, start: number) { - let pos = start - while (pos < command.length) { - const next = trivia(command, pos) - if (next !== pos) { - pos = next - continue - } - const end = line(command, pos) - const value = command.slice(pos, end) - if (/^using\s+(?:assembly|module|namespace|type)\b/i.test(value)) { - pos = end - continue - } - return pos - } - return pos -} - -function trivia(command: string, start: number) { - let pos = start - while (pos < command.length) { - while (/\s/.test(command[pos] ?? "")) pos++ - if (command[pos] === "#") { - pos = line(command, pos) - continue - } - if (command.startsWith("<#", pos)) { - const end = command.indexOf("#>", pos + 2) - if (end === -1) return command.length - pos = end + 2 - continue - } - return pos - } - return pos -} - -function line(command: string, start: number) { - const index = command.indexOf("\n", start) - if (index === -1) return command.length - return index + 1 -} - -function block(command: string, start: number, open: string, close: string) { - let depth = 0 - let quote: string | undefined - for (let pos = start; pos < command.length; pos++) { - const char = command[pos] - if (quote) { - if (quote === "'" && char === "'" && command[pos + 1] === "'") { - pos++ - continue - } - if (quote === '"' && char === "`") { - pos++ - continue - } - if (char === quote) quote = undefined - continue - } - if (char === "'" || char === '"') { - quote = char - continue - } - if (command.startsWith("<#", pos)) { - const end = command.indexOf("#>", pos + 2) - if (end === -1) return - pos = end + 1 - continue - } - if (char === "#") { - pos = line(command, pos) - 1 - continue - } - if (char === open) depth++ - if (char === close) { - depth-- - if (depth === 0) return pos + 1 - } - } -} +export { args, PowerShell } from "@opencode-ai/core/kilocode/powershell" diff --git a/packages/opencode/src/kilocode/tool/interactive-terminal.ts b/packages/opencode/src/kilocode/tool/interactive-terminal.ts index 52aae6739a..2bed97c337 100644 --- a/packages/opencode/src/kilocode/tool/interactive-terminal.ts +++ b/packages/opencode/src/kilocode/tool/interactive-terminal.ts @@ -2,7 +2,7 @@ import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" import { InteractiveTerminal } from "@/kilocode/interactive-terminal" import { Plugin } from "@/plugin" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { ShellPermission } from "@/tool/shell" import { Tool } from "@/tool/tool" import type { FSUtil } from "@opencode-ai/core/fs-util" diff --git a/packages/opencode/src/kilocode/tool/shell-unparsed.ts b/packages/opencode/src/kilocode/tool/shell-unparsed.ts new file mode 100644 index 0000000000..3680f2eb73 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/shell-unparsed.ts @@ -0,0 +1,22 @@ +import type { Node } from "web-tree-sitter" + +// tree-sitter-powershell drops commands containing a bare `--` into ERROR nodes +// instead of command nodes, so the shell permission scanner collected zero +// patterns and skipped the check entirely (Kilo-Org/kilocode#12326). Recover +// the failed command text from ERROR nodes, and fail closed with the raw input +// whenever the parse has errors and nothing else was recovered, so every +// executed command yields at least one permission pattern. The raw fallback +// also covers ERROR chunks without a command_name descendant (for example +// PowerShell backtick escapes), which can still contain runnable text. +export function unparsed(root: Node, commands: number): string[] { + if (!root.hasError && commands > 0) return [] + const failed = root + .descendantsOfType("ERROR") + .filter((node): node is Node => Boolean(node)) + .filter((node) => node.descendantsOfType("command_name").length > 0) + .map((node) => node.text.trim()) + .filter((text) => text.length > 0) + if (failed.length > 0) return failed + const raw = root.text.trim() + return raw ? [raw] : [] +} diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index c43e7e9378..3a1d9755d2 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -61,8 +61,17 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe resetTimeoutOnProgress: true, signal: options.abortSignal, timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, }, ) + if (result.isError) + throw new Error( + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) if (result.structuredContent === undefined || result.structuredContent === null) return result return { ...result, diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 5a40ffe48d..bcbb1033a1 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -8,16 +8,18 @@ if (process.platform === "win32" && !("type" in process)) { // kilocode_change end import path from "node:path" +import { pathToFileURL } from "node:url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { type Tool } from "ai" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { + ListRootsRequestSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, type Tool as MCPToolDef, @@ -45,6 +47,18 @@ import * as SandboxNetwork from "@/kilocode/sandbox/network" // kilocode_change import { McpCatalog } from "./catalog" const DEFAULT_TIMEOUT = 30_000 +const CLIENT_OPTIONS = { + capabilities: { + // Upstream issue anomalyco/opencode#11948 // kilocode_change + // sampling: {}, + // Upstream issue anomalyco/opencode#23066 // kilocode_change + // elicitation: {}, + // Upstream issue anomalyco/opencode#2308 // kilocode_change + roots: {}, + // Upstream issue anomalyco/opencode#28567 // kilocode_change + // tasks: {}, + }, +} satisfies ClientOptions // kilocode_change start - inject --rm for Docker containers to prevent stopped container accumulation export function ensureDockerRm(cmd: string, args: string[]): string[] { @@ -94,6 +108,14 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP type MCPClient = Client +function createClient(directory: string) { + const client = new Client({ name: "kilo", version: InstallationVersion }, CLIENT_OPTIONS) // kilocode_change + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + ) + return client +} + const StatusConnected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ identifier: "MCPStatusConnected", }) @@ -204,19 +226,21 @@ export const layer = Layer.effect( * Connect a client via the given transport with resource safety: * on failure the transport is closed; on success the caller owns it. */ - const connectTransport = (transport: Transport, timeout: number) => - Effect.acquireUseRelease( + const connectTransport = Effect.fn("MCP.connectTransport")(function* (transport: Transport, timeout: number) { + const directory = yield* InstanceState.directory + return yield* Effect.acquireUseRelease( Effect.succeed(transport), (t) => Effect.tryPromise({ try: () => { - const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change + const client = createClient(directory) return withTimeout(client.connect(t), timeout).then(() => client) }, catch: (e) => (e instanceof Error ? e : new Error(String(e))), }), (t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void), ) + }) const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } } @@ -806,10 +830,11 @@ export const layer = Layer.effect( authProvider, requestInit: mcpConfig.headers ? { headers: mcpConfig.headers } : undefined, }) + const directory = yield* InstanceState.directory return yield* Effect.tryPromise({ try: () => { - const client = new Client({ name: "kilo", version: InstallationVersion }) // kilocode_change + const client = createClient(directory) return client .connect(transport) .then(() => ({ authorizationUrl: "", oauthState, client }) satisfies AuthResult) diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index ef0450f66a..71bf180121 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -1,5 +1,6 @@ import { createConnection } from "net" import { createServer } from "http" +import { escapeHtml } from "@/util/html" import * as Log from "@opencode-ai/core/util/log" // kilocode_change import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH, parseRedirectUri } from "./oauth-provider" import * as KiloOAuthCallback from "../kilocode/mcp-oauth-callback" // kilocode_change @@ -52,7 +53,7 @@ const HTML_ERROR = (error: string) => `

Authorization Failed

An error occurred during authorization.

-
${error}
+
${escapeHtml(error)}
` @@ -80,6 +81,13 @@ function cleanupStateIndex(oauthState: string) { } } +function stopIfIdle() { + if (pendingAuths.size > 0 || !server) return + + server.close() + server = undefined +} + function handleRequest(req: import("http").IncomingMessage, res: import("http").ServerResponse) { const url = new URL(req.url || "/", `http://localhost:${currentPort}`) @@ -97,7 +105,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). // Enforce state parameter presence if (!state) { const errorMsg = "Missing required state parameter - potential CSRF attack" - res.writeHead(400, { "Content-Type": "text/html" }) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_ERROR(errorMsg)) return } @@ -111,13 +119,14 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). cleanupStateIndex(state) pending.reject(new Error(errorMsg)) } - res.writeHead(200, { "Content-Type": "text/html" }) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_ERROR(errorMsg)) + stopIfIdle() return } if (!code) { - res.writeHead(400, { "Content-Type": "text/html" }) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_ERROR("No authorization code provided")) return } @@ -125,7 +134,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). // Validate state parameter if (!pendingAuths.has(state)) { const errorMsg = "Invalid or expired state parameter - potential CSRF attack" - res.writeHead(400, { "Content-Type": "text/html" }) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_ERROR(errorMsg)) return } @@ -137,8 +146,9 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). cleanupStateIndex(state) pending.resolve(code) - res.writeHead(200, { "Content-Type": "text/html" }) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_SUCCESS) + stopIfIdle() } export async function ensureRunning(redirectUri?: string): Promise { @@ -212,6 +222,7 @@ export function waitForCallback(oauthState: string, mcpName?: string): Promise { return CopilotModels.get( base(auth.enterpriseUrl), { + ...(provider.options?.headers as Record | undefined), Authorization: `Bearer ${auth.refresh}`, "User-Agent": `opencode/${InstallationVersion}`, "X-GitHub-Api-Version": API_VERSION, diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index a75cf3de2e..8e6ebb9f83 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -148,11 +148,12 @@ export const layer = Layer.effect( const { Server } = yield* Effect.promise(() => import("../server/server")) + const serverUrl = Server.url const client = createKiloClient({ - baseUrl: "http://localhost:4096", + baseUrl: serverUrl?.toString() ?? "http://localhost:4096", directory: ctx.directory, headers: ServerAuth.headers(), - fetch: async (...args) => Server.Default().app.fetch(...args), + ...(serverUrl ? {} : { fetch: async (...args) => Server.Default().app.fetch(...args) }), }) const cfg = yield* config.get() const input: PluginInput = { diff --git a/packages/opencode/src/plugin/openai/codex.ts b/packages/opencode/src/plugin/openai/codex.ts index c8245caf63..262882669f 100644 --- a/packages/opencode/src/plugin/openai/codex.ts +++ b/packages/opencode/src/plugin/openai/codex.ts @@ -1,5 +1,6 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import * as Log from "@opencode-ai/core/util/log" // kilocode_change +import { escapeHtml } from "@/util/html" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { OAUTH_DUMMY_KEY } from "../../auth" import os from "os" @@ -203,7 +204,7 @@ const HTML_SUCCESS = ` ` -const HTML_ERROR = (error: string) => ` +export const renderOAuthError = (error: string) => ` @@ -248,7 +249,7 @@ const HTML_ERROR = (error: string) => `

Authorization Failed

An error occurred during authorization.

-
${error}
+
${escapeHtml(error)}
` @@ -281,8 +282,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = errorDescription || error pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -290,8 +291,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = "Missing authorization code" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -299,8 +300,8 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } const errorMsg = "Invalid state - potential CSRF attack" pendingOAuth?.reject(new Error(errorMsg)) pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(HTML_ERROR(errorMsg)) + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }) + res.end(renderOAuthError(errorMsg)) return } @@ -311,7 +312,7 @@ async function startOAuthServer(): Promise<{ port: number; redirectUri: string } .then((tokens) => current.resolve(tokens)) .catch((err) => current.reject(err)) - res.writeHead(200, { "Content-Type": "text/html" }) + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(HTML_SUCCESS) return } diff --git a/packages/opencode/src/plugin/pty-environment.ts b/packages/opencode/src/plugin/pty-environment.ts new file mode 100644 index 0000000000..67321372f1 --- /dev/null +++ b/packages/opencode/src/plugin/pty-environment.ts @@ -0,0 +1,24 @@ +export * as PluginPtyEnvironment from "./pty-environment" + +import { PtyEnvironment } from "@opencode-ai/server/pty-environment" +import { Effect, Layer } from "effect" +import { InstanceStore } from "@/project/instance-store" +import { Plugin } from "." + +export const layer = Layer.effect( + PtyEnvironment.Service, + Effect.gen(function* () { + const plugin = yield* Plugin.Service + const instances = yield* InstanceStore.Service + return PtyEnvironment.Service.of({ + get: Effect.fn("PtyEnvironment.get")(function* (input) { + return yield* instances.provide( + { directory: input.directory }, + plugin + .trigger("shell.env", { cwd: input.cwd }, { env: {} as Record }) + .pipe(Effect.map((result) => result.env)), + ) + }), + }) + }), +) diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index 669e86bb5b..fd42116d7c 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -2,6 +2,7 @@ import type { Hooks, PluginInput } from "@kilocode/plugin" import { OAUTH_DUMMY_KEY } from "../auth" import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { escapeHtml } from "@/util/html" // Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from // non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships @@ -74,25 +75,6 @@ function generateState(): string { return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) } -export function escapeHtml(value: string): string { - return value.replace(/[&<>"']/g, (char) => { - switch (char) { - case "&": - return "&" - case "<": - return "<" - case ">": - return ">" - case '"': - return """ - case "'": - return "'" - default: - return char - } - }) -} - interface TokenResponse { access_token: string refresh_token: string diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 8e78708465..3d08bee21f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -41,6 +41,8 @@ import { patchKiloProviderPrivacy, kiloSmallModelPriority, buildTimeoutSignal, + requestTimeout, + wrapFirstByte, } from "@/kilocode/provider/provider" import * as ModelsRefresh from "@/kilocode/provider/models-refresh" // kilocode_change end @@ -473,7 +475,7 @@ function custom(dep: CustomDep): Record { autoload: false, options: { headers: { - "HTTP-Referer": "https://kilo.ai/", // kilocode_change + "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change "X-Source": "kilo", // kilocode_change }, @@ -484,7 +486,7 @@ function custom(dep: CustomDep): Record { autoload: false, options: { headers: { - "HTTP-Referer": "https://kilo.ai/", // kilocode_change + "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }, }, @@ -494,7 +496,7 @@ function custom(dep: CustomDep): Record { autoload: provider.source === "config", options: { headers: { - "HTTP-Referer": "https://kilo.ai/", // kilocode_change + "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change "X-BILLING-INVOKE-ORIGIN": "KiloCode", // kilocode_change }, @@ -505,7 +507,7 @@ function custom(dep: CustomDep): Record { autoload: false, options: { headers: { - "http-referer": "https://kilo.ai/", // kilocode_change + "http-referer": "https://kilo.ai/", "x-title": "Kilo Code", // kilocode_change }, }, @@ -611,7 +613,7 @@ function custom(dep: CustomDep): Record { autoload: false, options: { headers: { - "HTTP-Referer": "https://kilo.ai/", // kilocode_change + "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }, }, @@ -811,7 +813,7 @@ function custom(dep: CustomDep): Record { if (!apiToken) { throw new Error( "CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " + - "Set it via environment variable or run `kilo auth cloudflare-ai-gateway`.", // kilocode_change + "Set it via environment variable or run `kilo auth cloudflare-ai-gateway`.", ) } @@ -844,7 +846,7 @@ function custom(dep: CustomDep): Record { apiKey: apiToken, ...(Object.values(opts).some((v) => v !== undefined) ? { options: opts } : {}), }) - const unified = createUnified() + const unified = createUnified({ apiKey: apiToken }) return { autoload: true, @@ -869,7 +871,7 @@ function custom(dep: CustomDep): Record { autoload: false, options: { headers: { - "HTTP-Referer": "https://kilo.ai/", // kilocode_change + "HTTP-Referer": "https://kilo.ai/", "X-Title": "Kilo Code", // kilocode_change }, }, @@ -1077,7 +1079,7 @@ export const Info = Schema.Struct({ description: optionalOmitUndefined(Schema.String), // kilocode_change source: Schema.Literals(["env", "config", "custom", "api"]), env: Schema.Array(Schema.String), - key: optionalOmitUndefined(Schema.String), // kilocode_change + key: optionalOmitUndefined(Schema.String), metadata: optionalOmitUndefined(ProviderMetadata), // kilocode_change options: Schema.Record(Schema.String, Schema.Any), models: Schema.Record(Schema.String, Model), @@ -1763,6 +1765,11 @@ export const layer = Layer.effect( const opts = init ?? {} const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined const timeout = buildTimeoutSignal(options) // kilocode_change - use cancellable timeout for connection phase + // kilocode_change start - extend the same deadline to the first response byte + const firstByteMs = requestTimeout(options) + const firstByteCtl = firstByteMs === undefined ? undefined : new AbortController() + const deadline = firstByteMs === undefined ? undefined : Date.now() + firstByteMs + // kilocode_change end const headerTimeoutMs = headerTimeout === false ? undefined : headerTimeout const headerTimeoutCtl = typeof headerTimeoutMs === "number" ? timeoutController(headerTimeoutMs) : undefined const signals: AbortSignal[] = [] @@ -1771,6 +1778,7 @@ export const layer = Layer.effect( if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) if (headerTimeoutCtl) signals.push(headerTimeoutCtl.signal) if (timeout.signal) signals.push(timeout.signal) // kilocode_change + if (firstByteCtl) signals.push(firstByteCtl.signal) // kilocode_change const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) if (combined) opts.signal = combined @@ -1783,8 +1791,12 @@ export const layer = Layer.effect( timeout: false, }).finally(() => headerTimeoutCtl?.clear()) timeout.clear() - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl) + // kilocode_change start - hand the remaining deadline to the first-byte guard + const remaining = deadline !== undefined ? deadline - Date.now() : undefined + const live = remaining !== undefined && firstByteCtl ? wrapFirstByte(res, Math.max(remaining, 1), firstByteCtl) : res + if (!chunkAbortCtl) return live + return wrapSSE(live, chunkTimeout, chunkAbortCtl) + // kilocode_change end } catch (err) { timeout.clear() throw err diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index a8d5d25f29..c0b14184d0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -226,7 +226,7 @@ function normalizeMessages( if ( model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLocaleLowerCase().includes("devstral") + model.api.id.toLowerCase().includes("devstral") ) { const scrub = (id: string) => { return id @@ -732,7 +732,6 @@ export function variants(model: Provider.Model): Record + +function isPlainObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +// Mirrors Codex's Rust JSON schema compatibility lowering for OpenAI tool schemas. +function sanitizeOpenAISchema(value: unknown): unknown { + const types = ["string", "number", "boolean", "integer", "object", "array", "null"] + const compositionKeys = ["anyOf", "oneOf", "allOf"] + + // JSON Schema's boolean form (`true`/`false`) is unsupported by OpenAI tool schemas. + if (typeof value === "boolean") return { type: "string" } + if (Array.isArray(value)) return value.map(sanitizeOpenAISchema) + if (!isPlainObject(value)) return value + + const result: JsonRecord = {} + + if (typeof value.$ref === "string") result.$ref = value.$ref + if (typeof value.description === "string") result.description = value.description + if ("const" in value) result.enum = [value.const] + else if (Array.isArray(value.enum)) result.enum = value.enum + + if (isPlainObject(value.properties)) { + result.properties = Object.fromEntries( + Object.entries(value.properties).map(([key, item]) => [key, sanitizeOpenAISchema(item)]), + ) + } + + if (Array.isArray(value.required)) { + result.required = value.required.filter((item) => typeof item === "string") + } + + if ("items" in value) result.items = sanitizeOpenAISchema(value.items) + + if ("additionalProperties" in value) { + result.additionalProperties = + typeof value.additionalProperties === "boolean" + ? value.additionalProperties + : sanitizeOpenAISchema(value.additionalProperties) + } + + for (const key of compositionKeys) { + if (Array.isArray(value[key])) result[key] = value[key].map(sanitizeOpenAISchema) + } + + for (const key of ["$defs", "definitions"]) { + if (isPlainObject(value[key])) { + result[key] = Object.fromEntries( + Object.entries(value[key]).map(([name, item]) => [name, sanitizeOpenAISchema(item)]), + ) + } + } + + const schemaTypes = + typeof value.type === "string" + ? types.includes(value.type) + ? [value.type] + : [] + : Array.isArray(value.type) + ? value.type.filter((item) => typeof item === "string" && types.includes(item)) + : [] + + if (schemaTypes.length === 0 && (typeof result.$ref === "string" || compositionKeys.some((key) => key in result))) { + return result + } + + // MCP schemas may omit `type` while still using keywords that imply one. + // Keep the schema usable after unsupported keywords are dropped. + const inferredTypes = + schemaTypes.length > 0 + ? schemaTypes + : ["properties", "required", "additionalProperties"].some((key) => key in value) + ? ["object"] + : ["items", "prefixItems"].some((key) => key in value) + ? ["array"] + : "enum" in result || "format" in value + ? ["string"] + : ["minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf"].some((key) => key in value) + ? ["number"] + : [] + + if (inferredTypes.length === 0) return {} + + result.type = inferredTypes.length === 1 ? inferredTypes[0] : inferredTypes + if (inferredTypes.includes("object") && !("properties" in result)) result.properties = {} + if (inferredTypes.includes("array") && !("items" in result)) result.items = { type: "string" } + return result +} + export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 { /* if (["openai", "azure"].includes(providerID)) { @@ -1446,6 +1535,11 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 } */ + if (model.api.npm === "@ai-sdk/openai" || model.api.npm === "@ai-sdk/azure") { + schema = sanitizeOpenAISchema(schema) as JSONSchema7 + // Codex also applies lossy compaction above 4 KB; defer that until OpenCode needs the same schema budget. + } + if (model.providerID === "moonshotai" || model.api.id.toLowerCase().includes("kimi")) { const sanitizeMoonshot = (obj: unknown): unknown => { if (obj === null || typeof obj !== "object") return obj diff --git a/packages/opencode/src/pty-preparation.ts b/packages/opencode/src/pty-preparation.ts deleted file mode 100644 index f978d2efd2..0000000000 --- a/packages/opencode/src/pty-preparation.ts +++ /dev/null @@ -1,46 +0,0 @@ -export * as PtyPreparation from "./pty-preparation" - -import { Config } from "@/config/config" -import * as InstanceState from "@/effect/instance-state" -import { Plugin } from "@/plugin" -import { Shell } from "@/shell/shell" -import { Pty } from "@opencode-ai/core/pty" -import { KiloPtySelfCommand } from "@/kilocode/pty/self-command" // kilocode_change - ported from the deleted @/pty module -import { Effect } from "effect" - -export const prepareCreate = Effect.fn("PtyPreparation.prepareCreate")(function* (input: Pty.CreateInput) { - const config = yield* Config.Service - const plugin = yield* Plugin.Service - // kilocode_change start - resolve Kilo self-commands (e.g. bare `kilo`) to the real binary + args + project cwd - const resolved = KiloPtySelfCommand.resolve({ - command: input.command, - args: input.args ? [...input.args] : undefined, - cwd: input.cwd, - }) - const command = resolved.command || Shell.preferred((yield* config.get()).shell) - const baseArgs = resolved.args ?? [] - const cwd = resolved.cwd || (yield* InstanceState.context).directory - // kilocode_change end - const args = Shell.login(command) ? [...baseArgs, "-l"] : [...baseArgs] - const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} }) - const env = { - ...process.env, - ...input.env, - ...shell.env, - TERM: "xterm-256color", - KILO_TERMINAL: "1", - } as Record - // kilocode_change start - ported from the deleted @/pty module. - // Don't leak the kilo server's auth credential into user shells: anything the shell forks (npm - // post-install, `curl | bash`, compromised tools) would otherwise see the password for free. Users - // who need `kilo run`/`kilo tui attach` to auto-connect from a kilo-spawned terminal pass --password. - delete env.KILO_SERVER_PASSWORD - delete env.KILO_SERVER_USERNAME - // kilocode_change end - if (process.platform === "win32") { - env.LC_ALL = "C.UTF-8" - env.LC_CTYPE = "C.UTF-8" - env.LANG = "C.UTF-8" - } - return { command, args, cwd, title: input.title, env } -}) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index 3ab7564b38..77bf29ba44 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -27,6 +27,10 @@ const ConsoleStateResponse = Schema.Struct({ switchableOrgCount: NonNegativeInt, }).annotate({ identifier: "ConsoleState" }) +const CapabilitiesResponse = Schema.Struct({ + backgroundSubagents: Schema.Boolean, +}).annotate({ identifier: "ExperimentalCapabilities" }) + const ConsoleOrgOption = Schema.Struct({ accountID: Schema.String, accountEmail: Schema.String, @@ -106,6 +110,7 @@ export const WorktreeDiffFileQuery = Schema.Struct({ // kilocode_change end export const ExperimentalPaths = { + capabilities: "/experimental/capabilities", console: "/experimental/console", consoleOrgs: "/experimental/console/orgs", consoleSwitch: "/experimental/console/switch", @@ -125,6 +130,16 @@ export const ExperimentalApi = HttpApi.make("experimental") .add( HttpApiGroup.make("experimental") .add( + HttpApiEndpoint.get("capabilities", ExperimentalPaths.capabilities, { + query: WorkspaceRoutingQuery, + success: described(CapabilitiesResponse, "Experimental capabilities"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "experimental.capabilities.get", + summary: "Get experimental capabilities", + description: "Get experimental features enabled on the OpenCode server.", + }), + ), HttpApiEndpoint.get("console", ExperimentalPaths.console, { query: WorkspaceRoutingQuery, success: described(ConsoleStateResponse, "Active Console provider metadata"), @@ -156,7 +171,7 @@ export const ExperimentalApi = HttpApi.make("experimental") OpenApi.annotations({ identifier: "experimental.console.switchOrg", summary: "Switch active Console org", - description: "Persist a new active Console account/org selection for the current local Kilo state.", // kilocode_change + description: "Persist a new active Console account/org selection for the current local Kilo state.", }), ), HttpApiEndpoint.get("tool", ExperimentalPaths.tool, { @@ -274,7 +289,7 @@ export const ExperimentalApi = HttpApi.make("experimental") identifier: "experimental.session.list", summary: "List sessions", description: - "Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", // kilocode_change + "Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", }), ), HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts index e84b202916..f580a0dd24 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts @@ -53,6 +53,10 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper const background = yield* BackgroundJob.Service const flags = yield* RuntimeFlags.Service + const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () { + return { backgroundSubagents: flags.experimentalBackgroundSubagents } + }) + const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () { const [state, groups] = yield* Effect.all( [ @@ -270,25 +274,24 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper return yield* mcp.resources() }) - return ( - handlers - .handle("console", getConsole) - .handle("consoleOrgs", listConsoleOrgs) - .handle("consoleSwitch", switchConsole) - .handle("tool", tool) - .handle("toolIDs", toolIDs) - .handle("worktree", worktree) - .handle("worktreeCreate", worktreeCreate) - .handle("worktreeRemove", worktreeRemove) - .handle("worktreeReset", worktreeReset) - // kilocode_change start - .handle("worktreeDiff", worktreeDiff) - .handle("worktreeDiffSummary", worktreeDiffSummary) - .handle("worktreeDiffFile", worktreeDiffFile) - // kilocode_change end - .handle("session", session) - .handle("sessionBackground", sessionBackground) - .handle("resource", resource) - ) + return handlers + .handle("capabilities", capabilities) + .handle("console", getConsole) + .handle("consoleOrgs", listConsoleOrgs) + .handle("consoleSwitch", switchConsole) + .handle("tool", tool) + .handle("toolIDs", toolIDs) + .handle("worktree", worktree) + .handle("worktreeCreate", worktreeCreate) + .handle("worktreeRemove", worktreeRemove) + .handle("worktreeReset", worktreeReset) + // kilocode_change start + .handle("worktreeDiff", worktreeDiff) + .handle("worktreeDiffSummary", worktreeDiffSummary) + .handle("worktreeDiffFile", worktreeDiffFile) + // kilocode_change end + .handle("session", session) + .handle("sessionBackground", sessionBackground) + .handle("resource", resource) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index 0058c66b52..538920b000 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -1,23 +1,22 @@ import * as InstanceState from "@/effect/instance-state" import { registerDisposer } from "@/effect/instance-registry" import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref" -import { PtyPreparation } from "@/pty-preparation" +import { Plugin } from "@/plugin" import { Pty } from "@opencode-ai/core/pty" -import { handlePtyInput } from "@opencode-ai/core/pty/input" +import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { LocationServiceMap } from "@opencode-ai/core/location-layer" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" -import { Shell } from "@/shell/shell" -import { EffectBridge } from "@/effect/bridge" -import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors" +import { Shell } from "@opencode-ai/core/shell" +import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, } from "@/server/shared/pty-ticket" -import { Effect, Layer, Option, Schema } from "effect" +import { Effect, Layer, Option, Queue, Schema } from "effect" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -36,10 +35,14 @@ const ticketScope = Effect.gen(function* () { return { directory: instance?.directory, workspaceID } }) +// Legacy surface compatibility: before exited-session retention, sessions vanished the moment +// their process exited. These routes preserve that observable behavior — exited sessions are +// invisible here — while the canonical /api/pty surface exposes them until removal. export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) => Effect.gen(function* () { const tickets = yield* PtyTicket.Service const cors = yield* CorsConfig + const plugin = yield* Plugin.Service const locations = yield* LocationServiceMap const unregister = registerDisposer((directory) => Effect.runPromise(locations.invalidate(Location.Ref.make({ directory: AbsolutePath.make(directory) }))), @@ -59,33 +62,42 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }) const list = Effect.fn("PtyHttpApi.list")(function* () { - return yield* pty(Pty.Service.use((service) => service.list())) + const sessions = yield* pty(Pty.Service.use((service) => service.list())) + return sessions.filter((info) => info.status === "running") }) const create = Effect.fn("PtyHttpApi.create")(function* (ctx: { payload: typeof Pty.CreateInput.Type }) { + const cwd = ctx.payload.cwd || (yield* InstanceState.context).directory + const shell = yield* plugin.trigger("shell.env", { cwd }, { env: {} as Record }) return yield* pty( Pty.Service.use((service) => - Effect.flatMap( - PtyPreparation.prepareCreate({ - ...ctx.payload, - args: ctx.payload.args ? [...ctx.payload.args] : undefined, - env: ctx.payload.env ? { ...ctx.payload.env } : undefined, - }), - service.create, - ), + service.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + cwd, + env: { ...ctx.payload.env, ...shell.env }, + }), ), ) }) const get = Effect.fn("PtyHttpApi.get")(function* (ctx: { params: { ptyID: PtyID } }) { return yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe( - Effect.catchTag("Pty.NotFoundError", (error) => - Effect.fail( + Effect.catchTag( + "Pty.NotFoundError", + (error) => new ApiError.PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}`, }), - ), + ), + Effect.flatMap((info) => + info.status === "running" + ? Effect.succeed(info) + : new ApiError.PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), ), ) }) @@ -94,6 +106,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler params: { ptyID: PtyID } payload: typeof Pty.UpdateInput.Type }) { + yield* get(ctx) return yield* pty( Pty.Service.use((service) => service.update(ctx.params.ptyID, { @@ -102,26 +115,27 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler }), ), ).pipe( - Effect.catchTag("Pty.NotFoundError", (error) => - Effect.fail( + Effect.catchTag( + "Pty.NotFoundError", + (error) => new ApiError.PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}`, }), - ), ), ) }) const remove = Effect.fn("PtyHttpApi.remove")(function* (ctx: { params: { ptyID: PtyID } }) { + yield* get(ctx) yield* pty(Pty.Service.use((service) => service.remove(ctx.params.ptyID))).pipe( - Effect.catchTag("Pty.NotFoundError", (error) => - Effect.fail( + Effect.catchTag( + "Pty.NotFoundError", + (error) => new ApiError.PtyNotFoundError({ ptyID: error.ptyID, message: `PTY session not found: ${error.ptyID}`, }), - ), ), ) return true @@ -131,16 +145,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler const request = yield* HttpServerRequest.HttpServerRequest if (request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || !validOrigin(request, cors)) return yield* new ApiError.PtyForbiddenError({ message: "Invalid PTY connect token request" }) - yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe( - Effect.catchTag("Pty.NotFoundError", (error) => - Effect.fail( - new ApiError.PtyNotFoundError({ - ptyID: error.ptyID, - message: `PTY session not found: ${error.ptyID}`, - }), - ), - ), - ) + yield* get(ctx) return yield* tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) }) }) @@ -180,7 +185,7 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne request: HttpServerRequest.HttpServerRequest }) { const exists = yield* pty(Pty.Service.use((service) => service.get(ctx.params.ptyID))).pipe( - Effect.as(true), + Effect.map((info) => info.status === "running"), Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), ) if (!exists) return HttpServerResponse.empty({ status: 404 }) @@ -214,48 +219,53 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne yield* closeAccepted(WebSocketTracker.SERVER_CLOSING_EVENT()) return HttpServerResponse.empty() } - const bridge = yield* EffectBridge.make() - const writeScoped = (effect: Effect.Effect) => { - bridge.fork(effect.pipe(Effect.catch(() => Effect.void))) - } - let closed = false - const adapter = { - get readyState() { - return closed ? 3 : 1 - }, - send: (data: string | Uint8Array | ArrayBuffer) => { - if (closed) return - writeScoped(write(data instanceof ArrayBuffer ? new Uint8Array(data) : data)) - }, - close: (code?: number, reason?: string) => { - if (closed) return - closed = true - writeScoped(write(new Socket.CloseEvent(code, reason))) - }, - } - const handler = yield* pty( - Pty.Service.use((service) => service.connect(ctx.params.ptyID, adapter, cursor)), - ).pipe( - Effect.catchTag("Pty.NotFoundError", () => - closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), - ), - ) - if (!handler) return HttpServerResponse.empty() - // The handshake runs inside `socket.runRaw`, after the input callback is - // registered, so the client cannot send frames before PTY input is wired. - yield* socket - .runRaw((message) => handlePtyInput(handler, message)) - .pipe( - Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), - Effect.ensuring( - Effect.sync(() => { - closed = true - handler.onClose() - }), - ), - Effect.orDie, - ) + // Outbound frames flow through one queue drained by a single writer so replay, live + // output, and the close frame keep their order. + const outbox = yield* Queue.unbounded() + const attachment = yield* pty( + Pty.Service.use((service) => + service.attach(ctx.params.ptyID, { + cursor, + onData: (chunk) => Queue.offerUnsafe(outbox, chunk), + onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)), + }), + ), + ).pipe( + Effect.catchTags({ + "Pty.NotFoundError": () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + "Pty.ExitedError": () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + }), + ) + if (!attachment) return HttpServerResponse.empty() + + for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk) + Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor)) + attachment.activate() + + const drain = Effect.gen(function* () { + while (true) { + const item = yield* Queue.take(outbox) + yield* write(item) + if (item instanceof Socket.CloseEvent) return + } + }) + + // The reader runs concurrently with the writer; whichever finishes first ends the + // connection and the attachment is always released. + yield* Effect.race( + drain, + socket.runRaw((message) => { + const decoded = PtyProtocol.decodeInput(message) + if (decoded !== undefined) attachment.write(decoded) + }), + ).pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring(Effect.sync(() => attachment.detach())), + Effect.orDie, + ) return HttpServerResponse.empty() }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index de84bac3a9..9aef07e512 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -22,6 +22,7 @@ import { MCP } from "@/mcp" import { McpAuth } from "@/mcp/auth" import { Permission } from "@/permission" import { Plugin } from "@/plugin" +import { PluginPtyEnvironment } from "@/plugin/pty-environment" import { InstanceStore } from "@/project/instance-store" import { Project } from "@/project/project" import { Vcs } from "@/project/vcs" @@ -71,7 +72,7 @@ import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { SessionProjector } from "@opencode-ai/core/session/projector" import { lazy } from "@/util/lazy" -import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/server/cors" import { serveUIEffect } from "@/server/shared/ui" import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" @@ -189,6 +190,7 @@ const serverRoutes = HttpApiBuilder.layer(Api).pipe( // kilocode_change start - effective references must be ready before any V2 location consumer runs Layer.provide(handlers.pipe(Layer.provide(locationServiceMapLayer), Layer.provide(referenceReconcilerLayer))), // kilocode_change end + Layer.provide(PluginPtyEnvironment.layer), Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]), ) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fdf1bf1a15..a82bf47e9b 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -11,7 +11,7 @@ import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" -import type { CorsOptions } from "./cors" +import type { CorsOptions } from "@opencode-ai/server/cors" import { lazy } from "@/util/lazy" import * as KiloListener from "@/kilocode/server/listener" // kilocode_change @@ -76,7 +76,7 @@ export async function openapi() { return OpenApi.fromApi(PublicApi) } -export let url: URL +export let url: URL | undefined export async function listen(opts: ListenOptions): Promise { const listener = await Effect.runPromise(listenEffect(opts)) @@ -94,16 +94,15 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect) { +function makeStop(state: ListenerState, unpublishMdns: Effect.Effect, listenerUrl: URL) { return Effect.gen(function* () { const forceCloseOnce = yield* Effect.cached(forceClose(state).pipe(Effect.ignore)) - const closeScopeOnce = yield* Effect.cached(Scope.close(state.scope, Exit.void).pipe(Effect.ignore)) + const closeScopeOnce = yield* Effect.cached( + Scope.close(state.scope, Exit.void).pipe( + Effect.ignore, + Effect.ensuring( + Effect.sync(() => { + if (url === listenerUrl) url = undefined + }), + ), + ), + ) return (close?: boolean) => Effect.gen(function* () { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e480f34dd0..f1520cc2e6 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -36,7 +36,7 @@ import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" import { Instruction } from "./instruction" import { Plugin } from "../plugin" -import MAX_STEPS from "../session/prompt/max-steps.txt" +import { MAX_STEPS_PROMPT } from "@opencode-ai/core/session/runner/max-steps" import { ToolRegistry } from "@/tool/registry" import { MCP } from "../mcp" import { LSP } from "@/lsp/lsp" @@ -55,7 +55,7 @@ import { Tool } from "@/tool/tool" import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { ShellID } from "@/tool/shell/id" import { FSUtil } from "@opencode-ai/core/fs-util" import { Truncate } from "@/tool/truncate" @@ -755,7 +755,7 @@ export const layer = Layer.effect( const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) { const agentName = input.agent - const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() // kilocode_change + const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!ag) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -1095,7 +1095,7 @@ export const layer = Layer.effect( if (mime === "application/x-directory") { const args = { filePath: filepath } - const exit = yield* execRead(args).pipe(Effect.exit) // kilocode_change - list only; child bytes need separate reads + const exit = yield* execRead(args).pipe(Effect.exit) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) yield* Effect.logError("failed to read directory", { error, filepath }) @@ -1412,17 +1412,17 @@ export const layer = Layer.effect( // kilocode_change end } - // kilocode_change start — unblock tools waiting on user input so any in-flight - // handle.process can return. Adding a new user message is the signal that any - // pending tool prompt is superseded, so we dismiss even on the noReply path. - // Critically we never cancel the in-flight fiber here — that would abort the - // streamText call mid-tokens and cut off the assistant reply. The enqueue call - // below serializes this prompt after the current turn's current LLM step, and - // runLoop checks hasFollowup between steps to break out once it has been - // enqueued during the turn. - yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)) - yield* question.dismissAll(input.sessionID) - if (input.noReply === true) return message + // kilocode_change start — register the queued follow-up before dismissing blockers. + // Otherwise the old turn can resume from a dismissed question and start another + // LLM step before hasFollowup observes the replacement prompt. + const dismiss = Effect.gen(function* () { + yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)).pipe(Effect.orDie) + yield* question.dismissAll(input.sessionID) + }) + if (input.noReply === true) { + yield* dismiss + return message + } // Queue tails and runner fibers can resume outside the HTTP request's // ambient instance context; bridge both Effect refs and legacy ALS. const bridge = yield* EffectBridge.make() @@ -1435,6 +1435,7 @@ export const layer = Layer.effect( ), ), // kilocode_change bridge.run(lastAssistant(input.sessionID)), + dismiss, ) // kilocode_change end }, @@ -1707,28 +1708,6 @@ export const layer = Layer.effect( if (step === 1) yield* summary.summarize({ sessionID, messageID: lastUser.id }).pipe(Effect.ignore, Effect.forkIn(scope)) - if (step > 1 && lastFinished) { - for (const m of msgs) { - // kilocode_change start - compare chronology, not generated IDs - const finishedBeforeMessage = - latest.finishedMessage && KiloSessionMessageOrder.compare(latest.finishedMessage, m) < 0 - if (m.info.role !== "user" || !finishedBeforeMessage) continue - // kilocode_change end - for (const p of m.parts) { - if (p.type !== "text" || p.ignored || p.synthetic) continue - if (!p.text.trim()) continue - p.text = [ - "", - "The user sent the following message:", - p.text, - "", - "Please address this message and continue with your tasks.", - "", - ].join("\n") - } - } - } - yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) // kilocode_change start — ephemeral context injection + post-summary @@ -1779,7 +1758,7 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS_PROMPT }] : [])], tools, model, toolChoice: format.type === "json_schema" ? "required" : undefined, @@ -2080,7 +2059,7 @@ export const layer = Layer.effect( yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID) - const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() // kilocode_change + const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() if (!agent) { const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name) const hint = available.length ? ` Available agents: ${available.join(", ")}` : "" @@ -2210,11 +2189,9 @@ export const PromptInput = Schema.Struct({ description: "@deprecated tools and permissions have been merged, you can set permissions on the session itself now", }), - // kilocode_change start - keep internal ephemeral tool controls out of the public prompt schema format: Schema.optional(SessionV1.Format), system: Schema.optional(Schema.String), variant: Schema.optional(Schema.String), - // kilocode_change end // kilocode_change start - managed product slow-snapshot policy snapshotInitialization: Schema.optional(Schema.Literal("wait")).annotate({ description: "Wait silently if snapshot initialization is slow instead of asking the user.", diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 55eb7d0fba..f6d78654cb 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -150,7 +150,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (!execute) continue const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema)) - const transformed = ProviderTransform.schema(input.model, schema) + const transformed = ProviderTransform.schema(input.model, { ...schema, properties: schema.properties ?? {} }) item.inputSchema = jsonSchema(transformed) item.execute = (args, opts) => run.promise( diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 429a04c7f9..27b73acae0 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -12,7 +12,7 @@ import { FSUtil } from "@opencode-ai/core/fs-util" import { fileURLToPath } from "url" import { Config } from "@/config/config" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { ShellID } from "./shell/id" import * as Truncate from "./truncate" @@ -20,6 +20,7 @@ import { Plugin } from "@/plugin" import { normalizeUrls } from "@/kilocode/util/url" // kilocode_change import { CommandTimeout } from "@/kilocode/command-timeout" // kilocode_change import { heredocs } from "@/kilocode/tool/shell-heredoc" // kilocode_change +import { unparsed } from "@/kilocode/tool/shell-unparsed" // kilocode_change import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { ShellPrompt, type Parameters } from "./shell/prompt" @@ -404,6 +405,14 @@ export const ShellPermission = Effect.gen(function* () { } } + // kilocode_change start - fail closed on commands the grammar failed to parse (#12326) + const lost = unparsed(root, nodes.length) + if (lost.length > 0) scan.access = "unknown" + for (const pattern of lost) { + scan.patterns.add(pattern) + } + // kilocode_change end + return scan }) diff --git a/packages/opencode/src/util/html.ts b/packages/opencode/src/util/html.ts new file mode 100644 index 0000000000..55028613f7 --- /dev/null +++ b/packages/opencode/src/util/html.ts @@ -0,0 +1,8 @@ +export function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} diff --git a/packages/opencode/sst-env.d.ts b/packages/opencode/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/opencode/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/opencode/test/acp/event.test.ts b/packages/opencode/test/acp/event.test.ts index 7ed42bd889..7067e7a300 100644 --- a/packages/opencode/test/acp/event.test.ts +++ b/packages/opencode/test/acp/event.test.ts @@ -517,7 +517,7 @@ describe("acp event routing", () => { expect(harness.updates).toHaveLength(0) }) - it("emits synthetic pending before the first running tool update", async () => { + it("exposes the shell command on the synthetic pending tool call", async () => { const harness = createHarness() await Effect.runPromise(harness.session.create({ id: "ses_tool", cwd: "/workspace" })) @@ -527,7 +527,14 @@ describe("acp event routing", () => { "tool_call", "tool_call_update", ]) - expect(harness.updates[0]?.update).toMatchObject({ status: "pending", toolCallId: "call_1" }) + expect(harness.updates[0]?.update).toMatchObject({ + status: "pending", + toolCallId: "call_1", + title: "printf hello", + kind: "execute", + locations: [{ path: "/workspace" }], + rawInput: { cmd: "printf hello", cwd: "/workspace" }, + }) expect(harness.updates[1]?.update).toMatchObject({ status: "in_progress", toolCallId: "call_1" }) }) diff --git a/packages/opencode/test/acp/tool.test.ts b/packages/opencode/test/acp/tool.test.ts index e7ad1c1b16..1713f6faf0 100644 --- a/packages/opencode/test/acp/tool.test.ts +++ b/packages/opencode/test/acp/tool.test.ts @@ -1,3 +1,4 @@ +import { resolve } from "path" import { describe, expect, test } from "bun:test" import { completedToolContent, @@ -37,7 +38,13 @@ describe("acp tool conversion", () => { expect(toLocations("external_directory", { directories: ["/tmp/outside"], patterns: ["/tmp/outside/*"] })).toEqual([ { path: "/tmp/outside" }, ]) - expect(toLocations("bash", { filePath: "/tmp/nope.ts", path: "/tmp" })).toEqual([]) + expect(toLocations("bash", { cmd: "pwd" }, "/workspace")).toEqual([{ path: "/workspace" }]) + // Relative workdir resolves against cwd via the platform path resolver (backslashes on Windows). + expect(toLocations("bash", { command: "pwd", workdir: "subdir" }, "/workspace")).toEqual([ + { path: resolve("/workspace", "subdir") }, + ]) + expect(toLocations("bash", { command: "pwd", workdir: "/abs/dir" }, "/workspace")).toEqual([{ path: "/abs/dir" }]) + expect(toLocations("bash", { command: "printf hello" })).toEqual([]) expect(toLocations("read", { path: "/tmp/missing-file-path.ts" })).toEqual([]) }) diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index 60391436ff..71e0dbc350 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -2,7 +2,7 @@ import { describe, expect } from "bun:test" import { Bus } from "@/bus" import { BackgroundProcess } from "@/kilocode/background-process" import { SessionID } from "@/session/schema" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Filesystem } from "@/util/filesystem" import { Global } from "@opencode-ai/core/global" import { Hash } from "@opencode-ai/core/util/hash" diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts index b138122f07..7da965292b 100644 --- a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -4,7 +4,7 @@ import { Effect, Layer, ManagedRuntime } from "effect" import { ShellTool } from "../../src/tool/shell" import { provideTestInstance } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture" -import { Shell } from "../../src/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { SessionID, MessageID } from "../../src/session/schema" import type { Permission } from "../../src/permission" import { Agent } from "../../src/agent/agent" diff --git a/packages/opencode/test/kilocode/command-timeout.test.ts b/packages/opencode/test/kilocode/command-timeout.test.ts index 9ec8547bdb..e242ef60ec 100644 --- a/packages/opencode/test/kilocode/command-timeout.test.ts +++ b/packages/opencode/test/kilocode/command-timeout.test.ts @@ -11,7 +11,7 @@ import { Plugin } from "@/plugin" import { Truncate } from "@/tool/truncate" import { Config } from "@/config/config" import { Agent } from "@/agent/agent" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { MessageID, SessionID } from "@/session/schema" import { RuntimeFlags } from "@/effect/runtime-flags" import { testEffect } from "../lib/effect" diff --git a/packages/opencode/test/kilocode/fixture/stall-plugin.ts b/packages/opencode/test/kilocode/fixture/stall-plugin.ts new file mode 100644 index 0000000000..144a7b37ab --- /dev/null +++ b/packages/opencode/test/kilocode/fixture/stall-plugin.ts @@ -0,0 +1,30 @@ +// Plugin used by the issue #8656 regression tests. +// +// It attaches the simulated socket from stall-transport.ts to the `mock` +// provider through the plugin `config` hook. This is the supported injection +// point: src/provider/provider.ts loads plugins before reading `cfg.provider` +// exactly so hooks can add options such as `fetch`. Injecting here keeps the +// simulation scoped to the test's own instance instead of replacing +// `globalThis.fetch` for the whole test process. + +import { createStallTransport } from "./stall-transport" + +type Options = { state?: unknown; answer?: unknown; provider?: unknown } + +type Draft = { + provider?: Record } | undefined> +} + +export default async (_input: unknown, options?: Options) => ({ + config: async (cfg: Draft) => { + const id = typeof options?.provider === "string" ? options.provider : "mock" + const provider = cfg.provider?.[id] + const state = typeof options?.state === "string" ? options.state : undefined + if (!provider || !state) return + provider.options ??= {} + provider.options["fetch"] = createStallTransport({ + state, + answer: typeof options?.answer === "string" ? options.answer : undefined, + }) + }, +}) diff --git a/packages/opencode/test/kilocode/fixture/stall-transport.ts b/packages/opencode/test/kilocode/fixture/stall-transport.ts new file mode 100644 index 0000000000..8f9c145c16 --- /dev/null +++ b/packages/opencode/test/kilocode/fixture/stall-transport.ts @@ -0,0 +1,119 @@ +// Simulated provider socket for the issue #8656 regression tests. +// +// Only the socket is simulated. The transport is injected as the provider's +// `fetch` option, so Kilo's own fetch wrapper (connection timeout, first-byte +// guard, SSE chunk watchdog), the openai-compatible SDK, SSE parsing, the +// session processor and the agent loop are all the production ones. +// +// Request script: +// 1. title request -> short text answer +// 2. no tool result in the messages -> a bash tool call +// 3. first request carrying a tool result -> SSE headers, body never sends a +// byte (the stall reported in #8656) +// 4. later requests carrying a tool result -> final text answer, so a bounded +// stall can recover through the normal retry path +// +// Progress is mirrored to a JSON file so tests can assert what the provider saw +// without sharing module state with the plugin that loads this file. + +export type StallState = { calls: number; stalls: number; recovered: number } + +const HEAD = { id: "chatcmpl-stall", object: "chat.completion.chunk", created: 0, model: "mock-model" } + +const chunk = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n` + +const usage = () => + chunk({ ...HEAD, choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }) + +function sse(body: BodyInit | null) { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }) +} + +function answer(value: string) { + return sse( + [ + chunk({ ...HEAD, choices: [{ index: 0, delta: { role: "assistant", content: value }, finish_reason: null }] }), + chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] }), + usage(), + "data: [DONE]\n\n", + ].join(""), + ) +} + +function toolCall(command: string) { + return sse( + [ + chunk({ + ...HEAD, + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "bash", arguments: JSON.stringify({ command }) }, + }, + ], + }, + finish_reason: null, + }, + ], + }), + chunk({ ...HEAD, choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }] }), + usage(), + "data: [DONE]\n\n", + ].join(""), + ) +} + +/** Response headers arrive, then the body never produces a byte. */ +function stalling() { + return sse( + new ReadableStream({ + start() {}, + cancel() { + // the first-byte guard cancels this reader when it gives up + }, + }), + ) +} + +export function createStallTransport(input: { state: string; answer?: string; command?: string }) { + const state: StallState = { calls: 0, stalls: 0, recovered: 0 } + const save = () => Bun.write(input.state, JSON.stringify(state)) + + return async (_input: unknown, init?: { body?: unknown }) => { + const body = typeof init?.body === "string" ? init.body : "" + state.calls++ + + if (body.includes("Generate a title")) { + await save() + return answer("Stall repro") + } + + if (!body.includes('"role":"tool"')) { + await save() + return toolCall(input.command ?? "echo repro-8656") + } + + if (state.stalls === 0) { + state.stalls++ + await save() + return stalling() + } + + state.recovered++ + await save() + return answer(input.answer ?? "recovered after the stall") + } +} + +export async function readStallState(file: string): Promise { + const handle = Bun.file(file) + if (!(await handle.exists())) return { calls: 0, stalls: 0, recovered: 0 } + return JSON.parse(await handle.text()) as StallState +} diff --git a/packages/opencode/test/kilocode/interactive-terminal.test.ts b/packages/opencode/test/kilocode/interactive-terminal.test.ts index bcd23bbdf5..d4f584655e 100644 --- a/packages/opencode/test/kilocode/interactive-terminal.test.ts +++ b/packages/opencode/test/kilocode/interactive-terminal.test.ts @@ -9,7 +9,7 @@ import { InteractiveTerminalTool } from "@/kilocode/tool/interactive-terminal" import { Plugin } from "@/plugin" import type { Permission } from "@/permission" import { MessageID, SessionID } from "@/session/schema" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Truncate } from "@/tool/truncate" import type { Tool } from "@/tool/tool" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" diff --git a/packages/opencode/test/kilocode/issue-8656-stall.test.ts b/packages/opencode/test/kilocode/issue-8656-stall.test.ts new file mode 100644 index 0000000000..c6c939e1ec --- /dev/null +++ b/packages/opencode/test/kilocode/issue-8656-stall.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { pathToFileURL } from "node:url" +import { provideTestInstance, tmpdir } from "../fixture/fixture" +import { readStallState } from "./fixture/stall-transport" +import { Server } from "../../src/server/server" + +// Regression coverage for https://github.com/Kilo-Org/kilocode/issues/8656 +// +// Reported symptom: after a tool call finished, `step-finish:tool-calls` was +// recorded and the next `step-start` never arrived, leaving the session busy +// with no error while the HTTP server stayed responsive. +// +// One transport state produces exactly that symptom: the follow-up request that +// carries the tool result gets response headers and then never receives a byte +// of body. The connection-phase timeout is cleared as soon as headers arrive, so +// before the fix nothing bounded that wait. +// +// The simulated socket is injected as the provider's `fetch` through the plugin +// `config` hook (see ../fixture/stall-plugin.ts), so the SDK, Kilo's fetch +// wrapper, SSE parsing, the processor and the agent loop stay production code +// and nothing global is patched. + +const PLUGIN = pathToFileURL(path.join(import.meta.dir, "fixture", "stall-plugin.ts")).href +const ANSWER = "recovered after the stall" + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +function settings(state: string, timeout: number | false) { + return { + $schema: "https://app.kilo.ai/config.json", + model: "mock/mock-model", + plugin: [[PLUGIN, { state, answer: ANSWER }]], + provider: { + mock: { + npm: "@ai-sdk/openai-compatible", + name: "Mock", + options: { baseURL: "http://127.0.0.1:1/v1", apiKey: "test", timeout }, + models: { + "mock-model": { + name: "Mock Model", + tool_call: true, + limit: { context: 128000, output: 8192 }, + cost: { input: 0, output: 0 }, + }, + }, + }, + }, + permission: { bash: "allow" }, + } +} + +function project(timeout: number | false) { + return tmpdir<{ state: string }>({ + init: async (dir) => { + const state = path.join(dir, "stall-state.json") + await Bun.write(path.join(dir, "opencode.json"), JSON.stringify(settings(state, timeout), null, 2)) + return { state } + }, + }) +} + +type Part = Record +type Message = { info: Record; parts: Part[] } + +function session(dir: string) { + const app = Server.Default().app + const headers = { "Content-Type": "application/json", "x-kilo-directory": dir } + const query = `directory=${encodeURIComponent(dir)}` + + const json = async (route: string, init?: RequestInit) => { + const res = await app.request(route, { headers, ...init }) + return await res.json() + } + + return { + create: async () => ((await json("/session", { method: "POST", body: "{}" })) as { id: string }).id, + prompt: (id: string, text: string) => + app.request(`/session/${id}/prompt_async`, { + method: "POST", + headers, + body: JSON.stringify({ parts: [{ type: "text", text }] }), + }), + abort: (id: string) => app.request(`/session/${id}/abort`, { method: "POST", headers }), + messages: (id: string) => json(`/session/${id}/message?${query}`) as Promise, + status: async (id: string) => { + const all = (await json(`/session/status?${query}`)) as Record + return all[id]?.type ?? "idle" + }, + } +} + +const timeline = (messages: Message[]) => + messages + .flatMap((m) => m.parts) + .map((p) => + p.type === "tool" ? `tool:${p.tool}:${p.state?.status}` : `${p.type}${p.reason ? `:${p.reason}` : ""}`, + ) + .join(" | ") + +async function until(check: () => Promise, budget: number) { + const deadline = Date.now() + budget + while (Date.now() < deadline) { + if (await check()) return true + await sleep(200) + } + return false +} + +/** Runs a prompt and waits until the provider has stalled the follow-up request. */ +async function stalled(api: ReturnType, state: string) { + const id = await api.create() + await api.prompt(id, "run the echo command") + const ready = await until(async () => { + const stalls = (await readStallState(state)).stalls + const parts = timeline(await api.messages(id)) + return stalls > 0 && parts.includes("step-finish:tool-calls") + }, 30_000) + expect(ready).toBe(true) + return id +} + +describe("issue #8656: provider stalls after a tool call", () => { + test("recovers instead of freezing once the stall is bounded", async () => { + await using tmp = await project(2_000) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const api = session(tmp.path) + const id = await stalled(api, tmp.extra.state) + + const done = await until(async () => (await api.status(id)) === "idle", 40_000) + const messages = await api.messages(id) + const assistant = messages.findLast((m) => m.info.role === "assistant") + const text = (assistant?.parts ?? []).find((p) => p.type === "text")?.text + const state = await readStallState(tmp.extra.state) + console.log("[repro] bounded ->", JSON.stringify({ timeline: timeline(messages), text, state })) + + // the turn finishes on its own: the stalled request was aborted, retried + // and answered, so the agent loop never sits frozen + expect(done).toBe(true) + expect(timeline(messages)).toContain("tool:bash:completed") + expect(state.stalls).toBe(1) + expect(state.recovered).toBeGreaterThan(0) + expect(text).toContain(ANSWER) + expect(assistant?.info.error).toBeUndefined() + }, + }) + }, 120_000) + + test("still hangs while the provider holds the connection open and timeout is disabled", async () => { + await using tmp = await project(false) + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const api = session(tmp.path) + const id = await stalled(api, tmp.extra.state) + try { + await sleep(5_000) + const messages = await api.messages(id) + const parts = timeline(messages) + const assistant = messages.findLast((m) => m.info.role === "assistant") + console.log("[repro] timeout:false ->", JSON.stringify({ timeline: parts, status: await api.status(id) })) + + // the reported freeze, kept reachable only through the documented opt-out + expect(parts.endsWith("step-finish:tool-calls")).toBe(true) + expect(await api.status(id)).toBe("busy") + expect(assistant?.info.error).toBeUndefined() + + // the server itself stays responsive during the freeze + expect((await Server.Default().app.request("/global/health")).status).toBe(200) + } finally { + // never leave a wedged turn behind for fixture teardown + expect((await api.abort(id)).status).toBe(200) + expect(await until(async () => (await api.status(id)) === "idle", 15_000)).toBe(true) + } + }, + }) + }, 120_000) +}) diff --git a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts index 3a981cc1dc..5d395c9c31 100644 --- a/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts +++ b/packages/opencode/test/kilocode/permission/external-directory-allow.test.ts @@ -14,7 +14,7 @@ import { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Database } from "@opencode-ai/core/database/database" import { provideTestInstance } from "../../fixture/fixture" import { MessageID, SessionID } from "../../../src/session/schema" -import { Shell } from "../../../src/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Truncate } from "../../../src/tool/truncate" import { ShellTool } from "../../../src/tool/shell" import { Plugin } from "../../../src/plugin" diff --git a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts index abcad3f51a..a70a59928d 100644 --- a/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts +++ b/packages/opencode/test/kilocode/prompt-dismiss-contract.test.ts @@ -30,19 +30,16 @@ describe("prompt.ts Kilo-specific invariants", () => { expect(content).toContain("Suggestion.dismissAll") }) - test("dismissAll for suggestions and questions runs before enqueue, without cancelling the in-flight fiber", () => { + test("enqueue reserves the follow-up before dismissing blockers, without cancelling the in-flight fiber", () => { const content = fs.readFileSync(PROMPT_FILE, "utf-8") - // dismissAll for both suggestions and questions must precede the enqueue so - // an in-flight handle.process blocked on a pending tool prompt can return. - // Critically, the block must NOT call state.cancel or KiloSessionPromptQueue.reserve — - // either of those would abort the running streamText mid-tokens, which was - // the #9332 regression. Order: dismissAll(Suggestion), question.dismissAll, enqueue. + // Register the queued follow-up before dismissing blockers so the old turn + // observes hasFollowup when the question resumes. The enqueue reservation + // runs both dismissals before waiting for the prior queue tail. const block = content.match( - /kilocode_change start[^\n]*unblock tools[\s\S]*?Suggestion\.dismissAll[\s\S]*?question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue/, + /kilocode_change start[^\n]*register the queued follow-up[\s\S]*?Suggestion\.dismissAll[\s\S]*?question\.dismissAll[\s\S]*?KiloSessionPromptQueue\.enqueue\([\s\S]*?dismiss/, ) expect(block).not.toBeNull() expect(content).not.toMatch(/state\.cancel\(input\.sessionID\)/) - expect(content).not.toMatch(/KiloSessionPromptQueue\.reserve/) }) test("runLoop breaks out between LLM steps when a newer prompt was enqueued", () => { diff --git a/packages/opencode/test/kilocode/provider-saved-auth.test.ts b/packages/opencode/test/kilocode/provider-saved-auth.test.ts new file mode 100644 index 0000000000..6391d69cc2 --- /dev/null +++ b/packages/opencode/test/kilocode/provider-saved-auth.test.ts @@ -0,0 +1,78 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Provider } from "../../src/provider/provider" +import { testEffect } from "../lib/effect" + +const it = testEffect(Provider.defaultLayer) + +const auth = (value: Record, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.KILO_AUTH_CONTENT + process.env.KILO_AUTH_CONTENT = JSON.stringify(value) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) delete process.env.KILO_AUTH_CONTENT + else process.env.KILO_AUTH_CONTENT = previous + }), + ) + +it.instance( + "uses saved Azure resource metadata", + () => + auth( + { azure: { type: "api", key: "azure-key", metadata: { resourceName: "saved-resource" } } }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const item = (yield* provider.list())[ProviderV2.ID.make("azure")] + expect(item.key).toBe("azure-key") + expect(item.options.resourceName).toBe("saved-resource") + }), + ), + { config: {} }, +) + +it.instance( + "uses saved GitLab OAuth access", + () => + auth( + { gitlab: { type: "oauth", refresh: "refresh", access: "oauth-access", expires: Date.now() + 60_000 } }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const item = (yield* provider.list())[ProviderV2.ID.make("gitlab")] + expect(item.options.apiKey).toBe("oauth-access") + }), + ), + { config: {} }, +) + +it.instance( + "uses saved Cloudflare Workers AI account metadata", + () => + auth( + { + "cloudflare-workers-ai": { + type: "api", + key: "cloudflare-key", + metadata: { accountId: "saved-account" }, + }, + }, + Effect.gen(function* () { + const provider = yield* Provider.Service + const item = (yield* provider.list())[ProviderV2.ID.make("cloudflare-workers-ai")] + expect(item.key).toBe("cloudflare-key") + expect(item.options.apiKey).toBe("cloudflare-key") + const model = Object.values(item.models)[0] + const language = yield* provider.getLanguage(model) + const url = ( + language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } } + ).config.url({ path: "/chat/completions", modelId: model.id }) + expect(url).toBe("https://api.cloudflare.com/client/v4/accounts/saved-account/ai/v1/chat/completions") + }), + ), + { config: {} }, +) diff --git a/packages/opencode/test/kilocode/provider/first-byte.test.ts b/packages/opencode/test/kilocode/provider/first-byte.test.ts new file mode 100644 index 0000000000..5045032abc --- /dev/null +++ b/packages/opencode/test/kilocode/provider/first-byte.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" +import { requestTimeout, wrapFirstByte, REQUEST_TIMEOUT_MS } from "../../../src/kilocode/provider/provider" +import { ProviderError } from "../../../src/provider/error" + +const sse = (body: BodyInit | null) => + new Response(body, { headers: { "content-type": "text/event-stream" }, status: 200 }) + +// A body that delivers `head` immediately, then stays silent forever. +const stalling = (head?: string) => + new ReadableStream({ + start(ctrl) { + if (head) ctrl.enqueue(new TextEncoder().encode(head)) + }, + }) + +const drain = async (res: Response) => { + const reader = res.body!.getReader() + const out: string[] = [] + while (true) { + const part = await reader.read() + if (part.done) return out.join("") + out.push(new TextDecoder().decode(part.value)) + } +} + +describe("requestTimeout", () => { + test("defaults to the shared request timeout", () => { + expect(requestTimeout({})).toBe(REQUEST_TIMEOUT_MS) + }) + + test("honours an explicit value, disables only on false, and bounds invalid input", () => { + expect(requestTimeout({ timeout: 1234 })).toBe(1234) + expect(requestTimeout({ timeout: false })).toBeUndefined() + // invalid/unset values fall back to the default so the wait is always bounded + expect(requestTimeout({ timeout: 0 })).toBe(REQUEST_TIMEOUT_MS) + expect(requestTimeout({ timeout: -1 })).toBe(REQUEST_TIMEOUT_MS) + expect(requestTimeout({ timeout: "nope" })).toBe(REQUEST_TIMEOUT_MS) + expect(requestTimeout({ timeout: null })).toBe(REQUEST_TIMEOUT_MS) + }) +}) + +describe("wrapFirstByte", () => { + test("fails when the body never produces a byte", async () => { + const ctl = new AbortController() + const res = wrapFirstByte(sse(stalling()), 100, ctl) + + await expect(drain(res)).rejects.toBeInstanceOf(ProviderError.ResponseStreamError) + expect(ctl.signal.aborted).toBe(true) + expect((ctl.signal.reason as Error).message).toContain("no response data within 100ms") + }) + + test("stays a passthrough once the first byte arrived, even if the stream then stalls", async () => { + const ctl = new AbortController() + const res = wrapFirstByte(sse(stalling("data: hello\n\n")), 100, ctl) + const reader = res.body!.getReader() + + const first = await reader.read() + expect(new TextDecoder().decode(first.value)).toBe("data: hello\n\n") + + // the guard must not arm again: idle gaps mid-stream stay opt-in (chunkTimeout) + const next = await Promise.race([ + reader.read().then(() => "chunk"), + new Promise((resolve) => setTimeout(() => resolve("still-waiting"), 300)), + ]) + expect(next).toBe("still-waiting") + expect(ctl.signal.aborted).toBe(false) + await reader.cancel("done") + }) + + test("passes a complete body through untouched", async () => { + const ctl = new AbortController() + const res = wrapFirstByte(sse("data: one\n\ndata: two\n\n"), 1_000, ctl) + expect(await drain(res)).toBe("data: one\n\ndata: two\n\n") + expect(ctl.signal.aborted).toBe(false) + }) + + test("is a no-op without a body or when disabled", () => { + const ctl = new AbortController() + const empty = new Response(null, { status: 204 }) + expect(wrapFirstByte(empty, 100, ctl)).toBe(empty) + const res = sse("data: x\n\n") + expect(wrapFirstByte(res, 0, ctl)).toBe(res) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/session.test.ts b/packages/opencode/test/kilocode/sandbox/session.test.ts index 07c87781f2..bc7d5d961e 100644 --- a/packages/opencode/test/kilocode/sandbox/session.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session.test.ts @@ -20,7 +20,7 @@ import { SandboxStore } from "@/kilocode/sandbox/store" import type { SessionID } from "@/session/schema" import { Session } from "@/session/session" import { SessionStatus } from "@/session/status" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Storage } from "@/storage/storage" import { SyncEvent } from "@/sync" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../../fixture/fixture" diff --git a/packages/opencode/test/kilocode/session-prompt-steering.test.ts b/packages/opencode/test/kilocode/session-prompt-steering.test.ts new file mode 100644 index 0000000000..dda61d2d02 --- /dev/null +++ b/packages/opencode/test/kilocode/session-prompt-steering.test.ts @@ -0,0 +1,209 @@ +import path from "path" +import { afterAll, beforeAll, expect, test } from "bun:test" +import fs from "fs/promises" +import os from "os" +import { Effect } from "effect" +import { Flag } from "@opencode-ai/core/flag/flag" +import { AppRuntime } from "../../src/effect/app-runtime" +import { MessageV2 } from "../../src/session/message-v2" +import { Session } from "../../src/session/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionID } from "../../src/session/schema" +import { + provideTestInstance, + disposeTestRuntime, + provideInstance, + testInstanceStoreLayer, + tmpdir, +} from "../fixture/fixture" +import { remove as cleanup } from "./cleanup" + +const previous = Flag.KILO_DB +const dbfile = path.join(os.tmpdir(), `kilo-prompt-steering-${process.pid}-${crypto.randomUUID()}.db`) + +beforeAll(async () => { + await fs.rm(dbfile, { force: true }) + Flag.KILO_DB = dbfile +}) + +afterAll(async () => { + await AppRuntime.dispose() + await disposeTestRuntime() + Flag.KILO_DB = previous + await Promise.all([dbfile, `${dbfile}-wal`, `${dbfile}-shm`].map(cleanup)) +}) + +function line(input: unknown) { + return `data: ${JSON.stringify(input)}\n\n` +} + +function chunk(input: { delta?: Record; finish?: string }) { + return { + id: "chatcmpl-steering-test", + object: "chat.completion.chunk", + choices: [{ delta: input.delta ?? {}, ...(input.finish ? { finish_reason: input.finish } : {}) }], + } +} + +function response(input: string) { + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue( + new TextEncoder().encode( + [ + line(chunk({ delta: { role: "assistant" } })), + line(chunk({ delta: { content: input } })), + line(chunk({ finish: "stop" })), + "data: [DONE]\n\n", + ].join(""), + ), + ) + ctrl.close() + }, + }) +} + +function question() { + const args = JSON.stringify({ + questions: [ + { + header: "Redirect", + question: "Continue the old task?", + options: [{ label: "Yes", description: "Continue" }], + }, + ], + }) + return new ReadableStream({ + start(ctrl) { + ctrl.enqueue( + new TextEncoder().encode( + [ + line( + chunk({ + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call-question", + type: "function", + function: { name: "question", arguments: args }, + }, + ], + }, + }), + ), + line(chunk({ finish: "tool_calls" })), + "data: [DONE]\n\n", + ].join(""), + ), + ) + ctrl.close() + }, + }) +} + +const sessions = { + create: (input: Parameters[0]) => + Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + messages: (sessionID: SessionID) => + Effect.runPromise( + Session.Service.use((svc) => svc.messages({ sessionID })).pipe(Effect.provide(Session.defaultLayer)), + ), +} + +async function wait(sessionID: SessionID) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const msgs = await sessions.messages(sessionID) + if ( + msgs.some((msg) => + msg.parts.some((part) => part.type === "tool" && part.tool === "question" && part.state.status === "running"), + ) + ) + return + await Bun.sleep(20) + } + throw new Error("question tool did not become pending") +} + +function scoped(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise) { + return Effect.runPromise( + SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe( + Effect.provide(SessionPrompt.defaultLayer), + provideInstance(dir), + Effect.provide(testInstanceStoreLayer), + Effect.scoped, + ), + ) +} + +function tail(body: Record): { role: string; content: unknown } | undefined { + const msgs = Array.isArray(body.messages) ? (body.messages as Array>) : [] + const item = msgs.findLast((msg) => msg.role !== "system") + if (!item || typeof item.role !== "string") return + return { role: item.role, content: item.content } +} + +test("runs queued steering before resuming a dismissed question turn", async () => { + const calls: Array> = [] + const server = Bun.serve({ + port: 0, + async fetch(req) { + if (!new URL(req.url).pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + calls.push((await req.json()) as Record) + return new Response(calls.length === 1 ? question() : response("steering acknowledged"), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }, + }) + + try { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + enabled_providers: ["alibaba"], + provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` } } }, + agent: { code: { model: "alibaba/qwen-plus" } }, + }), + ), + }) + await provideTestInstance({ + directory: tmp.path, + fn: () => + scoped(tmp.path, async (prompt) => { + const session = await sessions.create({ title: "Queued steering regression" }) + const first = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "perform the old task" }], + }), + ) + await wait(session.id) + const second = Effect.runPromise( + prompt.prompt({ + sessionID: session.id, + agent: "code", + parts: [{ type: "text", text: "stop the old task and inspect the failing test" }], + }), + ) + await first + const result = await second + expect(result.parts.some((part) => part.type === "text" && part.text.includes("steering acknowledged"))).toBe( + true, + ) + expect(calls).toHaveLength(2) + expect(tail(calls[1]!)?.role).toBe("user") + expect(JSON.stringify(tail(calls[1]!)?.content)).toContain("stop the old task and inspect the failing test") + expect(JSON.stringify(tail(calls[1]!)?.content)).not.toContain("") + }), + }) + } finally { + server.stop(true) + } +}, 60_000) diff --git a/packages/opencode/test/kilocode/shell/shell.test.ts b/packages/opencode/test/kilocode/shell/shell.test.ts index 57aa989fff..7b802cfbd0 100644 --- a/packages/opencode/test/kilocode/shell/shell.test.ts +++ b/packages/opencode/test/kilocode/shell/shell.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import * as PowerShell from "@/kilocode/shell/shell" -import { Shell } from "@/shell/shell" +import { Shell } from "@opencode-ai/core/shell" const command = `Write-Output "こんにちは 😀"; Write-Output '$value'; Write-Output \`tick\` Write-Output "done"` diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index 44708219c8..d63a631670 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -14,7 +14,7 @@ import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { BackgroundProcess } from "../../src/kilocode/background-process" -import { Shell } from "../../src/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import path from "path" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" diff --git a/packages/opencode/test/kilocode/test-profile.test.ts b/packages/opencode/test/kilocode/test-profile.test.ts index a9abc16759..9a677ec215 100644 --- a/packages/opencode/test/kilocode/test-profile.test.ts +++ b/packages/opencode/test/kilocode/test-profile.test.ts @@ -12,7 +12,7 @@ describe("test profiles", () => { expect(result.ok).toBe(true) if (!result.ok) return expect(result.files.length).toBeGreaterThan(20) - expect(result.files).toContain("pty/pty-shell.test.ts") + expect(result.files).toContain("server/httpapi-v2-pty.test.ts") expect(result.files).toContain("kilocode/cli/install-artifact.test.ts") expect(result.files).toContain("kilocode/cli/tui/thread.test.ts") expect(result.files).toContain("kilocode/sandbox/macos-confinement.test.ts") @@ -48,7 +48,7 @@ describe("test profiles", () => { ) expect(result.ok).toBe(true) if (!result.ok) return - expect(result.files).toContain("pty/pty-shell.test.ts") + expect(result.files).toContain("server/httpapi-v2-pty.test.ts") expect(result.files.some((file) => file.includes("\\"))).toBe(false) }) diff --git a/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts new file mode 100644 index 0000000000..88ad0e57d0 --- /dev/null +++ b/packages/opencode/test/kilocode/tool/shell-unparsed.test.ts @@ -0,0 +1,232 @@ +// Regression tests for Kilo-Org/kilocode#12326. +// +// tree-sitter-powershell dropped commands containing a bare `--` (for example +// `git checkout -- `) into ERROR nodes instead of command nodes, so the +// shell permission scanner collected zero patterns and the command executed +// with no permission evaluation at all, bypassing every bash rule including +// `"git *": "deny"` and `"*": "deny"`. The scanner now fails closed: failed +// command text is recovered from ERROR nodes, and any parse with errors that +// recovered nothing falls back to the raw command text (also covering ERROR +// chunks without a command_name descendant, such as backtick escapes). + +import { describe, expect, test } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import path from "path" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { FSUtil } from "@opencode-ai/core/fs-util" +import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { Permission } from "../../../src/permission" +import { ShellPermission } from "../../../src/tool/shell" +import { ShellTool } from "../../../src/tool/shell" +import { Shell } from "@opencode-ai/core/shell" +import { Config } from "../../../src/config/config" +import { Agent } from "../../../src/agent/agent" +import { Plugin } from "../../../src/plugin" +import { Truncate } from "../../../src/tool/truncate" +import { RuntimeFlags } from "../../../src/effect/runtime-flags" +import { SessionID, MessageID } from "../../../src/session/schema" +import { disposeAllInstances, provideInstance, testInstanceStoreLayer, tmpdir } from "../../fixture/fixture" +import { afterEach } from "bun:test" + +const layer = Layer.mergeAll(CrossSpawnSpawner.defaultLayer, FSUtil.defaultLayer, testInstanceStoreLayer) + +type ScanRequest = Omit + +async function scan(dir: string, command: string, shell: string) { + const requests: ScanRequest[] = [] + const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: (req: ScanRequest) => + Effect.sync(() => { + requests.push(req) + }), + } + await Effect.runPromise( + provideInstance(dir)( + Effect.gen(function* () { + const permission = yield* ShellPermission + yield* permission.ask(ctx, { command, cwd: dir, shell, description: "test" }) + }), + ).pipe(Effect.provide(layer)), + ) + return requests +} + +function patterns(requests: ScanRequest[]) { + return requests.filter((req) => req.permission === "bash").flatMap((req) => req.patterns) +} + +const deny = Permission.fromConfig({ + "*": "ask", + bash: { + "*": "ask", + "git *": "deny", + }, +}) + +function action(pattern: string) { + return Permission.evaluate("bash", pattern, deny).action +} + +afterEach(async () => { + await disposeAllInstances() +}) + +describe("shell permission scanner fails closed on unparsed commands", () => { + test("pwsh: bare '--' git commands now produce a denied pattern", async () => { + await using tmp = await tmpdir() + for (const command of ["git checkout -- file", "git restore -- file", "git log -- file", "git checkout -- ."]) { + const found = patterns(await scan(tmp.path, command, "pwsh")) + expect(found.length).toBeGreaterThan(0) + expect(found.map(action)).toContain("deny") + } + }) + + test("pwsh: bare '--' in a chained command no longer vanishes from the check", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file; git status", "pwsh")) + expect(found).toContain("git status") + expect(found.some((pattern) => pattern.includes("git checkout -- file"))).toBe(true) + expect(found.map(action)).toContain("deny") + }) + + test("pwsh: bare '--' in non-git commands produces a pattern that falls back to ask", async () => { + await using tmp = await tmpdir() + for (const command of ["npm run build -- --watch", "echo -- hi", "rm -rf -- file"]) { + const found = patterns(await scan(tmp.path, command, "pwsh")) + expect(found.length).toBeGreaterThan(0) + expect(found.map(action)).toContain("ask") + } + }) + + test("pwsh: valid commands are unchanged (no extra patterns, no new prompts)", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, "git status", "pwsh"))).toEqual(["git status"]) + expect(patterns(await scan(tmp.path, 'git checkout "--" file', "pwsh"))).toEqual(['git checkout "--" file']) + const found = patterns(await scan(tmp.path, "Write-Host foo; if ($?) { Write-Host bar }", "pwsh")) + expect(found).toContain("Write-Host foo") + expect(found).toContain("Write-Host bar") + expect(found.length).toBe(2) + }) + + test("pwsh: whitespace stays silent, comment-only input is checked instead of trusted", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, " ", "pwsh"))).toEqual([]) + expect(patterns(await scan(tmp.path, "# comment only", "pwsh"))).toEqual(["# comment only"]) + }) + + test("bash grammar: behavior is unchanged for direct, chained, and location commands", async () => { + await using tmp = await tmpdir() + expect(patterns(await scan(tmp.path, "git checkout -- file", "bash"))).toEqual(["git checkout -- file"]) + const chained = patterns(await scan(tmp.path, `cd ${tmp.path} && git checkout -- file`, "bash")) + expect(chained).toEqual(["git checkout -- file"]) + expect(patterns(await scan(tmp.path, `cd ${tmp.path}`, "bash"))).toEqual([]) + }) + + test("cmd-kind: bare '--' git commands still produce a denied pattern", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file", "cmd")) + expect(found).toEqual(["git checkout -- file"]) + expect(found.map(action)).toContain("deny") + }) + + test("pwsh: runnable text in an ERROR node without command_name falls back to the raw check", async () => { + await using tmp = await tmpdir() + // PowerShell interprets `n as a newline escape, so this input executes + // `git checkout -- file`, but the grammar drops that segment into an ERROR + // node with no command_name descendant while `echo ok` parses cleanly. + const found = patterns(await scan(tmp.path, "echo ok; `ngit checkout -- file", "pwsh")) + expect(found).toContain("echo ok; `ngit checkout -- file") + expect(found.map(action)).toContain("ask") + }) + + test("pwsh: partially parsed pipelines still fail closed with the raw text", async () => { + await using tmp = await tmpdir() + const found = patterns(await scan(tmp.path, "git checkout -- file | cat", "pwsh")) + expect(found).toContain("git checkout -- file | cat") + expect(found.map(action)).toContain("deny") + }) +}) + +const execLayer = Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + FSUtil.defaultLayer, + Plugin.defaultLayer, + Truncate.defaultLayer, + Config.defaultLayer, + Agent.defaultLayer, + RuntimeFlags.defaultLayer, + testInstanceStoreLayer, +) + +const powershells = + process.platform === "win32" + ? [Bun.which("pwsh"), Bun.which("powershell")].filter((shell): shell is string => Boolean(shell)) + : [] + +async function withShell(shell: string, fn: () => Promise) { + const prev = process.env.SHELL + process.env.SHELL = shell + Shell.acceptable.reset() + Shell.preferred.reset() + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.SHELL + if (prev !== undefined) process.env.SHELL = prev + Shell.acceptable.reset() + Shell.preferred.reset() + } +} + +// End-to-end coverage through the real shell tool and a real PowerShell binary. +// Runs only on the Windows CI runners, where pwsh/powershell exist. +describe("full tool execution through real powershell (windows only)", () => { + for (const shell of powershells) { + test(`asks for permission on a bare double dash command [${path.basename(shell, ".exe")}]`, async () => { + await using tmp = await tmpdir() + const requests: ScanRequest[] = [] + const stop = new Error("stop after permission") + await withShell(shell, () => + Effect.runPromise( + provideInstance(tmp.path)( + Effect.gen(function* () { + const info = yield* ShellTool + const tool = yield* info.init() + const exit = yield* tool + .execute( + { command: "git checkout -- file", description: "Restore a file from git" }, + { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make("msg_test"), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: (req: ScanRequest) => + Effect.sync(() => { + requests.push(req) + throw stop + }), + }, + ) + .pipe(Effect.exit) + const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(err instanceof Error && err.message).toBe(stop.message) + }), + ).pipe(Effect.provide(execLayer)), + ), + ) + const req = requests.find((r) => r.permission === "bash") + expect(req).toBeDefined() + expect(req!.patterns).toContain("git checkout -- file") + }) + } +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 4b01befccd..eb17a62dbc 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,6 +1,7 @@ import path from "node:path" +import { pathToFileURL } from "node:url" import { expect, mock, beforeEach } from "bun:test" -import { ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" +import { ListRootsRequestSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" import { testEffect } from "../lib/effect" @@ -40,6 +41,8 @@ interface MockClientState { { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > closed: boolean + clientOptions?: { capabilities?: { roots?: { listChanged?: boolean } } } + requestHandlers: Map Promise> notificationHandlers: Map any> } @@ -77,6 +80,7 @@ function getOrCreateClientState(name?: string): MockClientState { promptPages: {}, resourcePages: {}, closed: false, + requestHandlers: new Map(), notificationHandlers: new Map(), } clientStates.set(key, state) @@ -151,8 +155,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ _state!: MockClientState transport: any - constructor(_opts: any) { + constructor(_info: any, options?: MockClientState["clientOptions"]) { clientCreateCount++ + this._state = getOrCreateClientState(lastCreatedClientName) + this._state.clientOptions = options } async connect(transport: { start: () => Promise }) { @@ -162,6 +168,10 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ this._state = getOrCreateClientState(lastCreatedClientName) } + setRequestHandler(schema: unknown, handler: (...args: any[]) => Promise) { + this._state.requestHandlers.set(schema, handler) + } + setNotificationHandler(schema: unknown, handler: (...args: any[]) => any) { this._state?.notificationHandlers.set(schema, handler) } @@ -319,6 +329,28 @@ it.instance( ) // kilocode_change end +it.instance( + "advertises and lists the instance directory as its root", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + const { directory } = yield* TestInstance + lastCreatedClientName = "roots" + yield* mcp.add("roots", { type: "local", command: ["echo", "test"] }) + + const state = getOrCreateClientState("roots") + expect(state.clientOptions?.capabilities?.roots).toEqual({}) + expect(state.clientOptions?.capabilities?.roots?.listChanged).toBeUndefined() + + const handler = state.requestHandlers.get(ListRootsRequestSchema) + expect(handler).toBeDefined() + const result = yield* Effect.promise(() => handler?.() ?? Promise.reject(new Error("roots handler missing"))) + expect(result).toEqual({ roots: [{ uri: pathToFileURL(directory).href }] }) + }), + ), + { config: { mcp: {} } }, +) + it.instance( "local mcp cwd resolves relative paths against instance directory", () => diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 881790e1e5..9b46853c79 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -87,6 +87,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ // Mock the MCP SDK Client void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { + setRequestHandler() {} + async connect(transport: { start: () => Promise }) { await transport.start() } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 6beb80288e..7af2924157 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -95,6 +95,8 @@ void mock.module("@modelcontextprotocol/sdk/client/sse.js", () => ({ // Mock the MCP SDK Client to trigger OAuth flow void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: class MockClient { + setRequestHandler() {} + async connect(transport: { start: () => Promise }) { await transport.start() } diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index 58a4fa8c86..cac7146580 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -31,4 +31,42 @@ describe("McpOAuthCallback.ensureRunning", () => { await McpOAuthCallback.ensureRunning("http://127.0.0.1:18000/custom/callback") expect(McpOAuthCallback.isRunning()).toBe(true) }) + + test("stops after the callback completes", async () => { + const redirectUri = "http://127.0.0.1:18003/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + const callback = McpOAuthCallback.waitForCallback("success") + + const response = await fetch(`${redirectUri}?code=code&state=success`) + + expect(response.status).toBe(200) + expect(await callback).toBe("code") + expect(McpOAuthCallback.isRunning()).toBe(false) + }) + + test("escapes provider error markup in callback HTML", async () => { + const redirectUri = "http://127.0.0.1:18001/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + + const error = `` + const response = await fetch( + `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent(error)}`, + ) + const body = await response.text() + + expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8") + expect(body).toContain("<script>alert("xss" & 'more')</script>") + expect(body).not.toContain(error) + }) + + test("keeps normal provider errors readable", async () => { + const redirectUri = "http://127.0.0.1:18002/custom/callback" + await McpOAuthCallback.ensureRunning(redirectUri) + + const response = await fetch( + `${redirectUri}?state=test&error=access_denied&error_description=${encodeURIComponent("The user denied access")}`, + ) + + expect(await response.text()).toContain('
The user denied access
') + }) }) diff --git a/packages/opencode/test/plugin/codex.test.ts b/packages/opencode/test/plugin/codex.test.ts index 0be3ab63bb..a40b995358 100644 --- a/packages/opencode/test/plugin/codex.test.ts +++ b/packages/opencode/test/plugin/codex.test.ts @@ -4,6 +4,7 @@ import { parseJwtClaims, extractAccountIdFromClaims, extractAccountId, + renderOAuthError, type IdTokenClaims, } from "../../src/plugin/openai/codex" @@ -14,6 +15,14 @@ function createTestJwt(payload: object): string { } describe("plugin.codex", () => { + test("escapes provider errors in callback HTML", () => { + const error = `` + const html = renderOAuthError(error) + + expect(html).toContain("</div><script>alert("xss" & 'more')</script>") + expect(html).not.toContain(error) + }) + describe("parseJwtClaims", () => { test("parses valid JWT with claims", () => { const payload = { email: "test@example.com", chatgpt_account_id: "acc-123" } diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts index 3a7690b309..676f0acb6b 100644 --- a/packages/opencode/test/plugin/xai.test.ts +++ b/packages/opencode/test/plugin/xai.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test" import { accessTokenIsExpiring, buildAuthorizeUrl, - escapeHtml, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin, @@ -103,19 +102,6 @@ describe("plugin.xai", () => { }) }) - describe("escapeHtml", () => { - test("escapes HTML metacharacters", () => { - expect(escapeHtml(`
`)).toBe( - "</div><script>alert(1)</script><div class="x">", - ) - expect(escapeHtml("a & b")).toBe("a & b") - expect(escapeHtml("it's fine")).toBe("it's fine") - expect(escapeHtml("invalid_grant")).toBe("invalid_grant") - expect(escapeHtml("")).toBe("") - expect(escapeHtml("&<")).toBe("&<") - }) - }) - describe("loader", () => { test("returns no options unless stored auth is OAuth and exposes methods in order", async () => { const hooks = await XaiAuthPlugin({} as any) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index d339228ce6..5c84e9a777 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1253,6 +1253,207 @@ describe("ProviderTransform.schema - gemini non-object properties removal", () = }) }) +describe("ProviderTransform.schema - openai supported schema subset", () => { + const openaiModel = { + providerID: "openai", + api: { + id: "gpt-4.1", + npm: "@ai-sdk/openai", + }, + } as any + + test("removes unsupported JSON Schema keywords recursively", () => { + const result = ProviderTransform.schema(openaiModel, { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "Search", + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + format: "uri", + pattern: "^https://", + minLength: 1, + maxLength: 100, + default: "https://example.com", + }, + count: { + type: "integer", + minimum: 1, + maximum: 10, + multipleOf: 1, + }, + createdAt: { + format: "date-time", + }, + mode: { + const: "fast", + }, + tags: { + type: "array", + minItems: 1, + maxItems: 3, + uniqueItems: true, + }, + tuple: { + type: "array", + items: [ + { type: "number", minimum: 0 }, + { type: "string", pattern: "^ok$" }, + ], + }, + metadata: { + type: "object", + patternProperties: { + "^x-": { type: "string" }, + }, + additionalProperties: { + type: "string", + pattern: "^safe$", + }, + }, + }, + patternProperties: { + "^extra": { type: "string" }, + }, + required: ["query"], + additionalProperties: false, + } as any) as any + + expect(result).toEqual({ + type: "object", + properties: { + query: { + type: "string", + description: "Search query", + }, + count: { + type: "integer", + }, + createdAt: { + type: "string", + }, + mode: { + enum: ["fast"], + type: "string", + }, + tags: { + type: "array", + items: { type: "string" }, + }, + tuple: { + type: "array", + items: [{ type: "number" }, { type: "string" }], + }, + metadata: { + type: "object", + properties: {}, + additionalProperties: { + type: "string", + }, + }, + }, + required: ["query"], + additionalProperties: false, + }) + }) + + test("keeps local references and sanitizes definitions", () => { + const result = ProviderTransform.schema(openaiModel, { + type: "object", + properties: { + value: { + $ref: "#/$defs/Value", + description: "Referenced value", + examples: ["ignored"], + }, + }, + $defs: { + Value: { + type: "string", + pattern: "^value$", + description: "Definition description", + }, + Unused: { + type: "number", + minimum: 0, + }, + }, + } as any) as any + + expect(result.properties.value).toEqual({ + $ref: "#/$defs/Value", + description: "Referenced value", + }) + expect(result.$defs).toEqual({ + Value: { + type: "string", + description: "Definition description", + }, + Unused: { + type: "number", + }, + }) + }) + + test("does not sanitize non-openai providers", () => { + const result = ProviderTransform.schema( + { + providerID: "anthropic", + api: { + id: "claude-sonnet-4", + npm: "@ai-sdk/anthropic", + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ) as any + + expect(result.properties.query.pattern).toBe("^https://") + }) + + test.each([ + ["opencode", "@ai-sdk/openai"], + ["custom-openai-compatible", "@ai-sdk/openai"], + ["azure", "@ai-sdk/azure"], + ])("sanitizes %s models using %s", (providerID, npm) => { + expect( + ProviderTransform.schema( + { + providerID, + api: { + id: "custom-model", + npm, + }, + } as any, + { + type: "object", + properties: { + query: { + type: "string", + pattern: "^https://", + }, + }, + } as any, + ), + ).toEqual({ + type: "object", + properties: { + query: { + type: "string", + }, + }, + }) + }) +}) + describe("ProviderTransform.schema - moonshot $ref siblings", () => { const moonshotModel = { providerID: "moonshotai", @@ -2945,6 +3146,102 @@ describe("ProviderTransform.variants", () => { }) }) + test("glm-5.2 returns native effort variants for openai-compatible providers", () => { + const model = createMockModel({ + id: "zhipuai/glm-5.2", + providerID: "zhipuai", + api: { + id: "glm-5.2", + url: "https://open.bigmodel.cn/api/paas/v4", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("recognizes GLM-5.2 provider model IDs", () => { + for (const id of ["accounts/fireworks/models/glm-5p2", "zai-org-glm-5-2", "umans-glm-5.2"]) { + const model = createMockModel({ + id: `test/${id}`, + api: { + id, + url: "https://api.test.com", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + } + }) + + test("recognizes GLM-5.2 from the API ID when the configured model ID is an alias", () => { + const model = createMockModel({ + id: "custom/my-glm", + api: { + id: "accounts/fireworks/models/glm-5p2", + url: "https://api.fireworks.ai/inference/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoningEffort: "high" }, + max: { reasoningEffort: "max" }, + }) + }) + + test("glm-5.2 returns openrouter effort variants for openrouter", () => { + const model = createMockModel({ + id: "openrouter/z-ai/glm-5.2", + providerID: "openrouter", + api: { + id: "z-ai/glm-5.2", + url: "https://openrouter.ai/api/v1", + npm: "@openrouter/ai-sdk-provider", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { reasoning: { effort: "high" } }, + xhigh: { reasoning: { effort: "xhigh" } }, + }) + }) + + test("glm-5.2 returns effort variants for anthropic-compatible providers", () => { + const model = createMockModel({ + id: "zai-coding-plan/glm-5.2", + providerID: "zai-coding-plan", + api: { + id: "glm-5.2", + url: "https://api.z.ai/api/anthropic", + npm: "@ai-sdk/anthropic", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + high: { effort: "high" }, + max: { effort: "max" }, + }) + }) + + test("glm-5.2 falls back to provider defaults for other packages", () => { + const model = createMockModel({ + id: "test/glm-5.2", + api: { + id: "glm-5.2", + url: "https://api.test.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + low: { reasoningConfig: { type: "enabled", maxReasoningEffort: "low" } }, + medium: { reasoningConfig: { type: "enabled", maxReasoningEffort: "medium" } }, + high: { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }, + }) + }) + test("mistral models with reasoning support return variants", () => { const model = createMockModel({ id: "mistral/mistral-small-latest", diff --git a/packages/opencode/test/pty/pty-shell.test.ts b/packages/opencode/test/pty/pty-shell.test.ts deleted file mode 100644 index 6c04df0eed..0000000000 --- a/packages/opencode/test/pty/pty-shell.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" -import { Config } from "../../src/config/config" -import { Plugin } from "../../src/plugin" -import { PtyPreparation } from "../../src/pty-preparation" -import { Pty } from "@opencode-ai/core/pty" -import { Shell } from "../../src/shell/shell" -import { testEffect } from "../lib/effect" - -Shell.preferred.reset() - -const it = testEffect(Layer.mergeAll(Config.defaultLayer, Plugin.defaultLayer)) -const preparationIt = testEffect( - Layer.mergeAll( - Layer.mock(Config.Service)({ get: () => Effect.succeed({}) }), - Layer.mock(Plugin.Service)({ - trigger: (_name: Name, _input: Input, output: Output) => - Effect.sync(() => { - const result = output as { env: Record } - result.env.INPUT = "plugin" - result.env.FROM_PLUGIN = "plugin" - result.env.TERM = "plugin" - return output - }), - list: () => Effect.succeed([]), - init: () => Effect.void, - }), - ), -) - -const preparePty = (input: Pty.CreateInput) => PtyPreparation.prepareCreate(input) - -describe("pty shell args", () => { - if (process.platform !== "win32") return - - const ps = Bun.which("pwsh") || Bun.which("powershell") - if (ps) { - it.instance( - "does not add login args to pwsh", - () => - Effect.gen(function* () { - const info = yield* preparePty({ command: ps, title: "pwsh" }) - expect(info.args).toEqual([]) - }), - { timeout: 30000 }, - ) - } - - const bash = (() => { - const shell = Shell.preferred() - if (Shell.name(shell) === "bash") return shell - return Shell.gitbash() - })() - if (bash) { - it.instance( - "adds login args to bash", - () => - Effect.gen(function* () { - const info = yield* preparePty({ command: bash, title: "bash" }) - expect(info.args).toEqual(["-l"]) - }), - { timeout: 30000 }, - ) - } -}) - -describe("pty configured shell", () => { - const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash") - - it.instance( - "uses configured shell for default PTY command", - () => - Effect.gen(function* () { - if (!configured) return - - const info = yield* preparePty({ title: "configured" }) - if (process.platform === "win32") { - expect(info.command.toLowerCase()).toBe(configured.toLowerCase()) - } else { - expect(info.command).toBe(configured) - } - expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"]) - }), - configured ? { config: { shell: Shell.name(configured) } } : undefined, - { timeout: 30000 }, - ) -}) - -describe("pty environment preparation", () => { - preparationIt.instance("merges plugin environment before forced PTY values", () => - Effect.gen(function* () { - const input = { command: "/bin/sh", args: [] as string[], env: { INPUT: "caller" } } - const prepared = yield* preparePty(input) - - expect(input.args).toEqual([]) - expect(prepared.env.INPUT).toBe("plugin") - expect(prepared.env.FROM_PLUGIN).toBe("plugin") - expect(prepared.env.TERM).toBe("xterm-256color") - expect(prepared.env.KILO_TERMINAL).toBe("1") - }), - ) -}) diff --git a/packages/opencode/test/server/httpapi-event.test.ts b/packages/opencode/test/server/httpapi-event.test.ts index a9240e9a02..12f1dabb7d 100644 --- a/packages/opencode/test/server/httpapi-event.test.ts +++ b/packages/opencode/test/server/httpapi-event.test.ts @@ -5,8 +5,6 @@ import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/even // kilocode_change start - verify transformed EventV2 values at the legacy SSE boundary import { Catalog } from "@opencode-ai/core/catalog" import { EventV2 } from "@opencode-ai/core/event" -import { ModelV2 } from "@opencode-ai/core/model" -import { ProviderV2 } from "@opencode-ai/core/provider" import { SessionEvent } from "@opencode-ai/core/session/event" import { Prompt } from "@opencode-ai/core/session/prompt" import { DateTime, Fiber } from "effect" @@ -199,26 +197,21 @@ describe("event HttpApi", () => { expect(yield* readGlobal(reader)).toMatchObject({ payload: { type: "server.connected", properties: {} } }) yield* ready(count) const events = yield* EventV2Bridge.Service - const released = DateTime.makeUnsafe(1_750_000_000_123) - const model = new ModelV2.Info({ - ...ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), - time: { released }, - }) const catalogID = EventV2.ID.create() const catalog = yield* readGlobalUntil(reader, (event) => event.payload.id === catalogID).pipe( Effect.forkChild({ startImmediately: true }), ) - const catalogDomain = yield* events.publish(Catalog.Event.ModelUpdated, { model }, { id: catalogID }) + const catalogDomain = yield* events.publish(Catalog.Event.Updated, {}, { id: catalogID }) - expect(DateTime.isDateTime(catalogDomain.data.model.time.released)).toBe(true) - expect(properties(yield* Fiber.join(catalog)).model.time.released).toBe(1_750_000_000_123) + expect(catalogDomain.data).toEqual({}) + expect(properties(yield* Fiber.join(catalog))).toEqual({}) const globalID = EventV2.ID.create() const global = yield* readGlobalUntil(reader, (event) => event.payload.id === globalID).pipe( Effect.forkChild({ startImmediately: true }), ) yield* events - .publish(Catalog.Event.ModelUpdated, { model }, { id: globalID }) + .publish(Catalog.Event.Updated, {}, { id: globalID }) .pipe(Effect.provideService(InstanceRef, undefined)) expect((yield* Fiber.join(global)).directory).toBe("global") diff --git a/packages/opencode/test/server/httpapi-exercise/environment.ts b/packages/opencode/test/server/httpapi-exercise/environment.ts index 4594f5f329..84a07a2cf6 100644 --- a/packages/opencode/test/server/httpapi-exercise/environment.ts +++ b/packages/opencode/test/server/httpapi-exercise/environment.ts @@ -13,6 +13,7 @@ process.env.XDG_CACHE_HOME = path.join(exerciseGlobalRoot, "cache") process.env.KILO_DISABLE_SHARE = "true" process.env.KILO_DISABLE_SESSION_INGEST = "true" // kilocode_change - isolate the exerciser from async Kilo session sync process.env.KILO_DISABLE_PRESENCE = "1" // kilocode_change - presence now has a default Event Service URL; never open real sockets from the exerciser +process.env.KILO_DISABLE_CODEBASE_INDEXING = "vscode-no-workspace" // kilocode_change - route scenarios do not need an indexing worker per temp project export const exerciseConfigDirectory = path.join(exerciseGlobalRoot, "config", "opencode") export const exerciseDataDirectory = path.join(exerciseGlobalRoot, "data", "kilo") // kilocode_change diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index d0648500d3..f711e96e62 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -17,7 +17,7 @@ * - `.json(...)` / `.jsonEffect(...)` assert response shape and optional side effects. * - `.mutating()` tells the runner to reset isolated state after destructive routes. */ -import { Effect } from "effect" +import { Effect, Layer } from "effect" // kilocode_change import { OpenApi } from "effect/unstable/httpapi" import { TestLLMServer } from "../../lib/llm-server" import path from "path" @@ -623,6 +623,10 @@ const scenarios: Scenario[] = [ .get("/experimental/session", "experimental.session.list") .at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() })) .json(200, array), + http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => { + check(typeof body === "object" && body !== null, "capabilities should be an object") + check("backgroundSubagents" in body, "capabilities should report background subagents") + }), http.protected .post("/experimental/session/{sessionID}/background", "experimental.session.background") .mutating() @@ -802,6 +806,44 @@ const scenarios: Scenario[] = [ .seeded((ctx) => ctx.file("hello.txt", "hello\n")) .at((ctx) => ({ path: "/api/fs/find?query=hello&type=file", headers: ctx.headers() })) .json(200, locationData(array)), + http.protected.get("/api/pty", "v2.pty.list").json(200, locationData(array)), + http.protected + .post("/api/pty", "v2.pty.create") + .mutating() + .at((ctx) => ({ path: "/api/pty", headers: ctx.headers(), body: controlledPtyInput("HTTP API V2 PTY") })) + .json(200, locationData(object)), + http.protected + .get("/api/pty/{ptyID}", "v2.pty.get") + .at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(404, object, "status"), + http.protected + .put("/api/pty/{ptyID}", "v2.pty.update") + .mutating() + .at((ctx) => ({ + path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + body: { title: "missing" }, + })) + .json(404, object, "status"), + http.protected + .delete("/api/pty/{ptyID}", "v2.pty.remove") + .mutating() + .at((ctx) => ({ path: route("/api/pty/{ptyID}", { ptyID: "pty_httpapi_missing" }), headers: ctx.headers() })) + .json(404, object, "status"), + http.protected + .post("/api/pty/{ptyID}/connect-token", "v2.pty.connectToken") + .at((ctx) => ({ + path: route("/api/pty/{ptyID}/connect-token", { ptyID: "pty_httpapi_missing" }), + headers: { ...ctx.headers(), "x-kilo-ticket": "1" }, + })) + .json(404, object, "status"), + http.protected + .get("/api/pty/{ptyID}/connect", "v2.pty.connect") + .at((ctx) => ({ + path: route("/api/pty/{ptyID}/connect", { ptyID: "pty_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404, undefined, "none"), http.protected.get("/api/reference", "v2.reference.list").json(200, object), http.protected .get("/api/provider/{providerID}", "v2.provider.get") @@ -1628,7 +1670,16 @@ const llmScenarios = new Set([ ]) const main = Effect.gen(function* () { - yield* Effect.addFinalizer(() => Effect.promise(() => disposeApps()).pipe(Effect.andThen(cleanupExercisePaths))) + // kilocode_change start - dispose final non-mutating instances so shared test scopes can close + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + const modules = yield* Effect.promise(() => runtime()) + yield* Effect.promise(() => modules.disposeAllInstances()) + yield* Effect.promise(() => disposeApps()) + yield* cleanupExercisePaths + }), + ) + // kilocode_change end const options = parseOptions(Bun.argv.slice(2)) const modules = yield* Effect.promise(() => runtime()) const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi)) @@ -1670,10 +1721,17 @@ const main = Effect.gen(function* () { return undefined }) -Effect.runPromise(main.pipe(Effect.provide(TestLLMServer.layer), Effect.scoped)).then( +// kilocode_change start - route-only coverage must not acquire a listening fake LLM server +const llm = + parseOptions(Bun.argv.slice(2)).mode === "coverage" + ? Layer.mock(TestLLMServer)({ url: "http://coverage.invalid" }) + : TestLLMServer.layer + +Effect.runPromise(main.pipe(Effect.provide(llm), Effect.scoped)).then( () => process.exit(0), (error: unknown) => { console.error(`${color.red}${message(error)}${color.reset}`) process.exit(1) }, ) +// kilocode_change end diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index b069a8148d..0d65fd93d4 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import net from "node:net" +import path from "node:path" +import { pathToFileURL } from "node:url" import { Flag } from "@opencode-ai/core/flag/flag" import { Server } from "../../src/server/server" import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty" @@ -308,6 +310,57 @@ describe("HttpApi Server.listen", () => { expect(output).not.toContain("Sent HTTP response") }) + test("plugin client requests reuse the listening server instance", async () => { + await using tmp = await tmpdir({ + init: async (directory) => { + const plugin = path.join(directory, "plugin.ts") + const initialized = path.join(directory, "initialized.txt") + const completed = path.join(directory, "completed.txt") + await Bun.write( + plugin, + [ + "export default async function plugin(input) {", + ` await Bun.write(${JSON.stringify(initialized)}, (await Bun.file(${JSON.stringify(initialized)}).text().catch(() => "")) + "initialized\\n")`, + " setTimeout(async () => {", + " await input.client.config.get()", + ` await Bun.write(${JSON.stringify(completed)}, "completed")`, + " }, 50)", + " return {}", + "}", + "", + ].join("\n"), + ) + await Bun.write( + path.join(directory, "opencode.json"), + JSON.stringify({ formatter: false, lsp: false, plugin: [pathToFileURL(plugin).href] }), + ) + return { initialized, completed } + }, + }) + const previous = process.env.KILO_DISABLE_DEFAULT_PLUGINS + process.env.KILO_DISABLE_DEFAULT_PLUGINS = "1" + let listener: Awaited> | undefined + try { + listener = await startListener() + const response = await fetch(new URL("/config", listener.url), { + headers: { authorization: authorization(), "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + await withTimeout( + (async () => { + while (!(await Bun.file(tmp.extra.completed).exists())) await Bun.sleep(10) + })(), + 5_000, + "timed out waiting for plugin client request", + ) + expect(await Bun.file(tmp.extra.initialized).text()).toBe("initialized\n") + } finally { + if (listener) await stop(listener, "timed out cleaning up plugin client listener").catch(() => undefined) + if (previous === undefined) delete process.env.KILO_DISABLE_DEFAULT_PLUGINS + else process.env.KILO_DISABLE_DEFAULT_PLUGINS = previous + } + }) + test("port 0 prefers 4096 when free", async () => { if (!(await isPortFree(4096))) return const listener = await startListener() diff --git a/packages/opencode/test/server/httpapi-pty.test.ts b/packages/opencode/test/server/httpapi-pty.test.ts index 40274599d9..084ce9f713 100644 --- a/packages/opencode/test/server/httpapi-pty.test.ts +++ b/packages/opencode/test/server/httpapi-pty.test.ts @@ -136,6 +136,33 @@ describe("pty HttpApi bridge", () => { }) }) + testPty("hides exited sessions on the legacy surface", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const headers = { "x-kilo-directory": tmp.path } + const created = await app().request(PtyPaths.create, { + method: "POST", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 0"] }), + }) + expect(created.status).toBe(200) + const info = await created.json() + + // Exited sessions are retained by core for the canonical surface, but the legacy + // routes preserve pre-retention behavior: exited sessions are invisible here. + const deadline = Date.now() + 5_000 + while (Date.now() < deadline) { + const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + if (found.status === 404) break + await new Promise((resolve) => setTimeout(resolve, 50)) + } + const found = await app().request(PtyPaths.get.replace(":ptyID", info.id), { headers }) + expect(found.status).toBe(404) + + const list = await app().request(PtyPaths.list, { headers }) + expect(list.status).toBe(200) + expect(await list.json()).toEqual([]) + }) + testPty("disposes PTY sessions with their legacy instance", async () => { await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) const headers = { "x-kilo-directory": tmp.path } diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts new file mode 100644 index 0000000000..d2eb68f77a --- /dev/null +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -0,0 +1,284 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Context, Config as EffectConfig, Effect, Layer, Queue, Schema } from "effect" +import { NodeHttpServer, NodeServices } from "@effect/platform-node" +import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" +import * as Socket from "effect/unstable/socket/Socket" +import path from "path" +import { pathToFileURL } from "url" +import { mkdir } from "fs/promises" +import { Location } from "@opencode-ai/core/location" +import { Pty } from "@opencode-ai/core/pty" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const context = Context.empty() as Context.Context +const testPty = process.platform === "win32" ? test.skip : test + +function request(route: string, directory: string, init: RequestInit = {}) { + const headers = new Headers(init.headers) + headers.set("x-kilo-directory", directory) + return HttpApiApp.webHandler().handler( + new Request(`http://localhost${route}`, { + ...init, + headers, + }), + context, + ) +} + +const testStateLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Effect.promise(() => resetDatabase()) + yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase())) + }), +) + +const servedRoutes: Layer.Layer = HttpRouter.serve( + HttpApiApp.routes, + { disableListenLog: true, disableLogger: true }, +) + +const effectIt = testEffect( + Layer.mergeAll( + testStateLayer, + Socket.layerWebSocketConstructorGlobal, + servedRoutes.pipe( + Layer.provide(Socket.layerWebSocketConstructorGlobal), + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(NodeServices.layer), + ), + ), +) + +const directoryHeader = (dir: string) => HttpClientRequest.setHeader("x-kilo-directory", dir) + +const serverUrl = () => HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address))) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +describe("v2 pty HttpApi", () => { + testPty("serves location-wrapped PTY routes and retains exited sessions", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + + const empty = await request("/api/pty", tmp.path) + expect(empty.status).toBe(200) + expect(Schema.decodeUnknownSync(Location.response(Schema.Array(Pty.Info)))(await empty.json()).data).toEqual([]) + + const created = await request("/api/pty", tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "exit 4"], title: "v2" }), + }) + expect(created.status).toBe(200) + const body = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()) + expect(String(body.location.directory)).toBe(tmp.path) + expect(body.data.title).toBe("v2") + + // The canonical surface keeps exited sessions observable with their exit code. + const deadline = Date.now() + 5_000 + let info: { status: string; exitCode?: number } | undefined + while (Date.now() < deadline) { + const found = await request(`/api/pty/${body.data.id}`, tmp.path) + expect(found.status).toBe(200) + info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await found.json()).data + if (info.status === "exited") break + await new Promise((resolve) => setTimeout(resolve, 50)) + } + expect(info).toMatchObject({ status: "exited", exitCode: 4 }) + + const removed = await request(`/api/pty/${body.data.id}`, tmp.path, { method: "DELETE" }) + expect(removed.status).toBe(204) + + const missing = await request(`/api/pty/${body.data.id}`, tmp.path) + expect(missing.status).toBe(404) + expect(await missing.json()).toMatchObject({ _tag: "PtyNotFoundError", ptyID: body.data.id }) + }) + + testPty("rejects connect tokens without the CSRF header and connects with a valid ticket", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const created = await request("/api/pty", tmp.path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "/usr/bin/env", args: ["sh", "-c", "sleep 5"] }), + }) + expect(created.status).toBe(200) + const info = Schema.decodeUnknownSync(Location.response(Pty.Info))(await created.json()).data + + try { + const forbidden = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { method: "POST" }) + expect(forbidden.status).toBe(403) + expect(await forbidden.json()).toMatchObject({ _tag: "ForbiddenError" }) + + const token = await request(`/api/pty/${info.id}/connect-token`, tmp.path, { + method: "POST", + headers: { "x-kilo-ticket": "1" }, + }) + expect(token.status).toBe(200) + const ticket = Schema.decodeUnknownSync(Location.response(PtyTicket.ConnectToken))(await token.json()).data.ticket + expect(ticket).toBeTruthy() + + const invalid = await request(`/api/pty/${info.id}/connect?ticket=not-a-ticket`, tmp.path) + expect(invalid.status).toBe(403) + } finally { + await request(`/api/pty/${info.id}`, tmp.path, { method: "DELETE" }) + } + }) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "serves PTY websocket output and input through the canonical route", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + const created = yield* HttpClientRequest.post("/api/pty").pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ command: "/bin/cat", title: "v2-websocket" }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const body = yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json) + const info = body.data + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=-1&location[directory]=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe(Effect.catch(() => Effect.void)) + .pipe(Effect.forkScoped) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + yield* write("ping-v2\n") + expect(yield* takeUntil("ping-v2")).toContain("ping-v2") + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + + const removed = yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe( + directoryHeader(dir), + HttpClient.execute, + ) + expect(removed.status).toBe(204) + }), + ) + ;(process.platform === "win32" ? effectIt.live.skip : effectIt.live)( + "applies plugin shell environment before forced PTY values", + () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } }) + // kilocode_change start - verify child env precedence and credential stripping through the canonical PTY route + const previous = { + password: process.env.KILO_SERVER_PASSWORD, + username: process.env.KILO_SERVER_USERNAME, + } + yield* Effect.acquireRelease( + Effect.sync(() => { + process.env.KILO_SERVER_PASSWORD = "host-password" + process.env.KILO_SERVER_USERNAME = "host-username" + }), + () => + Effect.sync(() => { + if (previous.password === undefined) delete process.env.KILO_SERVER_PASSWORD + else process.env.KILO_SERVER_PASSWORD = previous.password + if (previous.username === undefined) delete process.env.KILO_SERVER_USERNAME + else process.env.KILO_SERVER_USERNAME = previous.username + }), + ) + const plugin = path.join(dir, "plugin.ts") + const cwd = path.join(dir, "child") + yield* Effect.promise(() => mkdir(cwd)) + yield* Effect.promise(() => + Bun.write( + plugin, + [ + "export default async () => ({", + ' "shell.env": (input, output) => {', + ' output.env.SHARED = "plugin"', + ' output.env.PLUGIN = "plugin"', + ' output.env.TERM = "plugin"', + ' output.env.KILO_TERMINAL = "plugin"', + ' output.env.KILO_PTY_ID = "plugin"', + ' output.env.KILO_SERVER_PASSWORD = "plugin-password"', + ' output.env.KILO_SERVER_USERNAME = "plugin-username"', + " output.env.HOOK_CWD = input.cwd", + " },", + "})", + "", + ].join("\n"), + ), + ) + yield* Effect.promise(() => + Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ plugin: [pathToFileURL(plugin).href], formatter: false, lsp: false }), + ), + ) + + const created = yield* HttpClientRequest.post("/api/pty").pipe( + directoryHeader(dir), + HttpClientRequest.bodyJson({ + command: "/bin/sh", + args: [ + "-c", + 'printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$KILO_TERMINAL" "$KILO_PTY_ID" "${KILO_SERVER_PASSWORD-unset}" "${KILO_SERVER_USERNAME-unset}" "$HOOK_CWD"; sleep 5', + ], + cwd, + env: { + CALLER: "caller", + SHARED: "caller", + TERM: "caller", + KILO_TERMINAL: "caller", + KILO_PTY_ID: "caller", + KILO_SERVER_PASSWORD: "caller-password", + KILO_SERVER_USERNAME: "caller-username", + }, + }), + Effect.flatMap(HttpClient.execute), + ) + expect(created.status).toBe(200) + const info = (yield* Schema.decodeUnknownEffect(Location.response(Pty.Info))(yield* created.json)).data + + const socket = yield* Socket.makeWebSocket( + `${(yield* serverUrl()).replace(/^http/, "ws")}/api/pty/${info.id}/connect?cursor=0&location[directory]=${encodeURIComponent(dir)}`, + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket + .runRaw((message) => + Queue.offer(messages, typeof message === "string" ? message : new TextDecoder().decode(message)), + ) + .pipe( + Effect.catch(() => Effect.void), + Effect.forkScoped, + ) + const write = yield* socket.writer + + const takeUntil = (expected: string, seen = ""): Effect.Effect => + Effect.gen(function* () { + const next = seen + (yield* Queue.take(messages).pipe(Effect.timeout("5 seconds"))) + if (next.includes(expected)) return next + return yield* takeUntil(expected, next) + }) + + const output = yield* takeUntil("caller|plugin|plugin|xterm-256color") + expect(output).toContain(`caller|plugin|plugin|xterm-256color|1|${info.id}|||${cwd}`) + // kilocode_change end + yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void)) + yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute) + }), + 30_000, // kilocode_change - external plugin loading and websocket setup can exceed Bun's 5s default + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d738af1b4f..f4cb5fcd10 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -50,7 +50,7 @@ import { SessionV2 } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" -import { Shell } from "../../src/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { Snapshot } from "../../src/snapshot" import { ToolRegistry } from "@/tool/registry" import { Truncate } from "@/tool/truncate" @@ -1527,7 +1527,7 @@ it.instance( } }), { git: true }, - 10_000, + 30_000, // kilocode_change - isolated suite load can delay queued live-loop cancellation ) // Queue semantics @@ -1639,7 +1639,14 @@ it.instance( const inputs = yield* llm.inputs expect(inputs).toHaveLength(2) - expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") + const messages = inputs.at(-1)?.messages + if (!Array.isArray(messages)) throw new Error("expected LLM messages") + // kilocode_change start - Kilo appends environment details to queued user prompts + expect(messages.at(-1)).toMatchObject({ + role: "user", + content: expect.arrayContaining([{ type: "text", text: "second" }]), + }) + // kilocode_change end }), 10_000, ) diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 1d60180c95..ccc21f2ee2 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -17,7 +17,7 @@ import { Database } from "@opencode-ai/core/database/database" import { eq } from "drizzle-orm" import { provideTmpdirInstance } from "../fixture/fixture" import { resetDatabase } from "../fixture/db" -import { pollWithTimeout, testEffect } from "../lib/effect" // kilocode_change +import { pollWithTimeout, testEffect } from "../lib/effect" const env = LayerNode.buildLayer(CrossSpawnSpawner.node) const it = testEffect(env) @@ -140,10 +140,10 @@ describe("ShareNext", () => { it.live("create posts share, persists it, and returns the result", () => provideTmpdirInstance( () => { - const seen: HttpClientRequest.HttpClientRequest[] = [] + const createRequests: HttpClientRequest.HttpClientRequest[] = [] const client = HttpClient.make((req) => { - seen.push(req) if (req.url.endsWith("/api/share")) { + createRequests.push(req) return Effect.succeed( json(req, { id: "shr_abc", @@ -168,9 +168,9 @@ describe("ShareNext", () => { expect(row?.url).toBe("https://legacy-share.example.com/share/abc") expect(row?.secret).toBe("sec_123") - expect(seen).toHaveLength(1) - expect(seen[0].method).toBe("POST") - expect(seen[0].url).toBe("https://legacy-share.example.com/api/share") + expect(createRequests).toHaveLength(1) + expect(createRequests[0].method).toBe("POST") + expect(createRequests[0].url).toBe("https://legacy-share.example.com/api/share") }).pipe(Effect.provide(integrationLayer(client))) }, { config: { enterprise: { url: "https://legacy-share.example.com" } } }, @@ -304,13 +304,13 @@ describe("ShareNext", () => { deletions: 0, status: "modified", }, - ], // kilocode_change + ], }) const sync = yield* pollWithTimeout( Effect.sync(() => seen[0]), "share sync was not sent", "3 seconds", - ) // kilocode_change + ) expect(seen).toHaveLength(1) expect(sync.url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") // kilocode_change diff --git a/packages/opencode/test/tool/shell.test.ts b/packages/opencode/test/tool/shell.test.ts index 1584c65b9c..1de2fbe2f4 100644 --- a/packages/opencode/test/tool/shell.test.ts +++ b/packages/opencode/test/tool/shell.test.ts @@ -5,7 +5,7 @@ import type * as Scope from "effect/Scope" import os from "os" import path from "path" import { Config } from "@/config/config" -import { Shell } from "../../src/shell/shell" +import { Shell } from "@opencode-ai/core/shell" import { ShellTool } from "../../src/tool/shell" import { Filesystem } from "@/util/filesystem" import { provideInstance, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" diff --git a/packages/opencode/test/util/html.test.ts b/packages/opencode/test/util/html.test.ts new file mode 100644 index 0000000000..952d5b58e6 --- /dev/null +++ b/packages/opencode/test/util/html.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test" +import { escapeHtml } from "../../src/util/html" + +describe("escapeHtml", () => { + test("escapes HTML metacharacters", () => { + expect(escapeHtml(`
`)).toBe( + "</div><script>alert(1)</script><div class="x">", + ) + expect(escapeHtml("a & b")).toBe("a & b") + expect(escapeHtml("it's fine")).toBe("it's fine") + expect(escapeHtml("invalid_grant")).toBe("invalid_grant") + expect(escapeHtml("")).toBe("") + expect(escapeHtml("&<")).toBe("&<") + }) +}) diff --git a/packages/plugin/sst-env.d.ts b/packages/plugin/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/plugin/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/script/sst-env.d.ts b/packages/script/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/script/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 38669ab43d..a1fd47267c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -78,6 +78,8 @@ import type { EventTuiPromptAppend2, EventTuiSessionSelect2, EventTuiToastShow2, + ExperimentalCapabilitiesGetErrors, + ExperimentalCapabilitiesGetResponses, ExperimentalConsoleGetErrors, ExperimentalConsoleGetResponses, ExperimentalConsoleListOrgsErrors, @@ -501,6 +503,20 @@ import type { V2ProviderGetResponses, V2ProviderListErrors, V2ProviderListResponses, + V2PtyConnectErrors, + V2PtyConnectResponses, + V2PtyConnectTokenErrors, + V2PtyConnectTokenResponses, + V2PtyCreateErrors, + V2PtyCreateResponses, + V2PtyGetErrors, + V2PtyGetResponses, + V2PtyListErrors, + V2PtyListResponses, + V2PtyRemoveErrors, + V2PtyRemoveResponses, + V2PtyUpdateErrors, + V2PtyUpdateResponses, V2QuestionRequestListErrors, V2QuestionRequestListResponses, V2ReferenceListErrors, @@ -811,6 +827,42 @@ export class ControlPlane extends HeyApiClient { } } +export class Capabilities extends HeyApiClient { + /** + * Get experimental capabilities + * + * Get experimental features enabled on the Kilo server. + */ + public get( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get< + ExperimentalCapabilitiesGetResponses, + ExperimentalCapabilitiesGetErrors, + ThrowOnError + >({ + url: "/experimental/capabilities", + ...options, + ...params, + }) + } +} + export class Console extends HeyApiClient { /** * Get active Console provider metadata @@ -1370,6 +1422,11 @@ export class Experimental extends HeyApiClient { return (this._controlPlane ??= new ControlPlane({ client: this.client })) } + private _capabilities?: Capabilities + get capabilities(): Capabilities { + return (this._capabilities ??= new Capabilities({ client: this.client })) + } + private _console?: Console get console(): Console { return (this._console ??= new Console({ client: this.client })) @@ -9896,10 +9953,24 @@ export class Credential extends HeyApiClient { public remove( parameters: { credentialID: string + location?: { + directory?: string + workspace?: string + } }, options?: Options, ) { - const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "credentialID" }] }]) + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "credentialID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) return (options?.client ?? this.client).delete( { url: "/api/credential/{credentialID}", @@ -9917,6 +9988,10 @@ export class Credential extends HeyApiClient { public update( parameters: { credentialID: string + location?: { + directory?: string + workspace?: string + } label?: string }, options?: Options, @@ -9927,6 +10002,7 @@ export class Credential extends HeyApiClient { { args: [ { in: "path", key: "credentialID" }, + { in: "query", key: "location" }, { in: "body", key: "label" }, ], }, @@ -10210,6 +10286,260 @@ export class Event2 extends HeyApiClient { } } +export class Pty2 extends HeyApiClient { + /** + * List PTY sessions + * + * List PTY sessions for a location, including exited sessions retained until removal. + */ + public list( + parameters?: { + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }]) + return (options?.client ?? this.client).get({ + url: "/api/pty", + ...options, + ...params, + }) + } + + /** + * Create PTY session + * + * Create a pseudo-terminal session for a location. + */ + public create( + parameters?: { + location?: { + directory?: string + workspace?: string + } + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "location" }, + { in: "body", key: "command" }, + { in: "body", key: "args" }, + { in: "body", key: "cwd" }, + { in: "body", key: "title" }, + { in: "body", key: "env" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Remove PTY session + * + * Terminate and remove one PTY session. + */ + public remove( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Get PTY session + * + * Get one PTY session, including its exit code once exited. + */ + public get( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + }) + } + + /** + * Update PTY session + * + * Update the title or viewport size of one PTY session. + */ + public update( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + title?: string + sessionID?: string + size?: { + rows: number + cols: number + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + { in: "body", key: "title" }, + { in: "body", key: "sessionID" }, + { in: "body", key: "size" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put({ + url: "/api/pty/{ptyID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Create PTY WebSocket token + * + * Create a short-lived single-use ticket for opening a PTY WebSocket connection. + */ + public connectToken( + parameters: { + ptyID: string + location?: { + directory?: string + workspace?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/pty/{ptyID}/connect-token", + ...options, + ...params, + }) + } + + /** + * Connect to PTY session + * + * Establish a WebSocket connection streaming PTY output and accepting terminal input. + */ + public connect( + parameters: { + ptyID: string + "location[directory]"?: string + "location[workspace]"?: string + cursor?: string + ticket?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "ptyID" }, + { in: "query", key: "location[directory]" }, + { in: "query", key: "location[workspace]" }, + { in: "query", key: "cursor" }, + { in: "query", key: "ticket" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/api/pty/{ptyID}/connect", + ...options, + ...params, + }) + } +} + export class Request2 extends HeyApiClient { /** * List pending question requests @@ -10451,6 +10781,11 @@ export class V2 extends HeyApiClient { return (this._event ??= new Event2({ client: this.client })) } + private _pty?: Pty2 + get pty(): Pty2 { + return (this._pty ??= new Pty2({ client: this.client })) + } + private _question?: Question3 get question(): Question3 { return (this._question ??= new Question3({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 84656fa1b8..dfc45ddeaf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -38,7 +38,8 @@ export type Event = | EventGlobalDisposed | EventGlobalConfigUpdated | EventPluginAdded - | EventCatalogModelUpdated + | EventIntegrationUpdated + | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated | EventSessionDeleted @@ -95,7 +96,6 @@ export type Event = | EventPermissionAsked | EventPermissionReplied | EventReferenceUpdated - | EventIntegrationUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventProjectDirectoriesUpdated @@ -1007,6 +1007,7 @@ export type Pty = { cwd: string status: "running" | "exited" pid: number + exitCode?: number sessionID?: string | null } @@ -1090,7 +1091,8 @@ export type GlobalEvent = { | EventGlobalDisposed | EventGlobalConfigUpdated | EventPluginAdded - | EventCatalogModelUpdated + | EventIntegrationUpdated + | EventCatalogUpdated | EventSessionCreated | EventSessionUpdated | EventSessionDeleted @@ -1147,7 +1149,6 @@ export type GlobalEvent = { | EventPermissionAsked | EventPermissionReplied | EventReferenceUpdated - | EventIntegrationUpdated | EventPermissionV2Asked | EventPermissionV2Replied | EventProjectDirectoriesUpdated @@ -1857,6 +1858,10 @@ export type Provider = { } } +export type ExperimentalCapabilities = { + backgroundSubagents: boolean +} + export type ConsoleState = { consoleManagedProviders: Array activeOrgName?: string @@ -3531,6 +3536,11 @@ export type ProviderNotFoundError = { message: string } +export type ForbiddenError = { + _tag: "ForbiddenError" + message: string +} + export type ProjectCopyError = { name: "ProjectCopyError" data: { @@ -3961,107 +3971,19 @@ export type EventPluginAdded = { } } -export type ModelV2Info = { +export type EventIntegrationUpdated = { id: string - providerID: string - family?: string - name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - capabilities: { - tools: boolean - input: Array - output: Array - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - stop?: Array - } - options?: { - [key: string]: unknown - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number + type: "integration.updated" + properties: { + [key: string]: unknown } } -export type EventCatalogModelUpdated = { +export type EventCatalogUpdated = { id: string - type: "catalog.model.updated" + type: "catalog.updated" properties: { - model: ModelV2Info + [key: string]: unknown } } @@ -4759,14 +4681,6 @@ export type EventReferenceUpdated = { } } -export type EventIntegrationUpdated = { - id: string - type: "integration.updated" - properties: { - [key: string]: unknown - } -} - export type PermissionV2Source = { type: "tool" messageID: string @@ -6029,26 +5943,106 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction -export type ProviderV2Info = { +export type ModelV2Info = { id: string + providerID: string + family?: string name: string - enabled: - | false + api: | { - via: "env" - name: string - } - | { - via: "credential" - credentialID: string - } - | { - via: "custom" - data: { + id: string + type: "aisdk" + package: string + url?: string + settings?: { [key: string]: unknown } } - env: Array + | { + id: string + type: "native" + url?: string + settings: { + [key: string]: unknown + } + } + capabilities: { + tools: boolean + input: Array + output: Array + } + request: { + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + generation?: { + maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + stop?: Array + } + options?: { + [key: string]: unknown + } + variant?: string + } + variants: Array<{ + id: string + headers: { + [key: string]: string + } + body: { + [key: string]: unknown + } + generation?: { + maxTokens?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + temperature?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + topP?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + topK?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + seed?: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + stop?: Array + } + options?: { + [key: string]: unknown + } + }> + time: { + released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN" + } + cost: Array<{ + tier?: { + type: "context" + size: number + } + input: number + output: number + cache: { + read: number + write: number + } + }> + status: "alpha" | "beta" | "deprecated" | "active" + enabled: boolean + limit: { + context: number + input?: number + output: number + } +} + +export type ProviderV2Info = { + id: string + name: string + disabled?: boolean api: | { type: "aisdk" @@ -6350,102 +6344,6 @@ export type EventMemoryError1 = { } } -export type ModelV2Info1 = { - id: string - providerID: string - family?: string - name: string - api: - | { - id: string - type: "aisdk" - package: string - url?: string - settings?: { - [key: string]: unknown - } - } - | { - id: string - type: "native" - url?: string - settings: { - [key: string]: unknown - } - } - capabilities: { - tools: boolean - input: Array - output: Array - } - request: { - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" - temperature?: number | "NaN" | "Infinity" | "-Infinity" - topP?: number | "NaN" | "Infinity" | "-Infinity" - topK?: number | "NaN" | "Infinity" | "-Infinity" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" - seed?: number | "NaN" | "Infinity" | "-Infinity" - stop?: Array - } - options?: { - [key: string]: unknown - } - variant?: string - } - variants: Array<{ - id: string - headers: { - [key: string]: string - } - body: { - [key: string]: unknown - } - generation?: { - maxTokens?: number | "NaN" | "Infinity" | "-Infinity" - temperature?: number | "NaN" | "Infinity" | "-Infinity" - topP?: number | "NaN" | "Infinity" | "-Infinity" - topK?: number | "NaN" | "Infinity" | "-Infinity" - frequencyPenalty?: number | "NaN" | "Infinity" | "-Infinity" - presencePenalty?: number | "NaN" | "Infinity" | "-Infinity" - seed?: number | "NaN" | "Infinity" | "-Infinity" - stop?: Array - } - options?: { - [key: string]: unknown - } - }> - time: { - released: number | "NaN" | "Infinity" | "-Infinity" - } - cost: Array<{ - tier?: { - type: "context" - size: number - } - input: number - output: number - cache: { - read: number - write: number - } - }> - status: "alpha" | "beta" | "deprecated" | "active" - enabled: boolean - limit: { - context: number - input?: number - output: number - } -} - export type EventTuiToastShow1 = { id: string type: "tui.toast.show" @@ -6897,6 +6795,36 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ExperimentalCapabilitiesGetData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/experimental/capabilities" +} + +export type ExperimentalCapabilitiesGetErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type ExperimentalCapabilitiesGetError = + ExperimentalCapabilitiesGetErrors[keyof ExperimentalCapabilitiesGetErrors] + +export type ExperimentalCapabilitiesGetResponses = { + /** + * Experimental capabilities + */ + 200: ExperimentalCapabilities +} + +export type ExperimentalCapabilitiesGetResponse = + ExperimentalCapabilitiesGetResponses[keyof ExperimentalCapabilitiesGetResponses] + export type ExperimentalConsoleGetData = { body?: never path?: never @@ -15319,7 +15247,12 @@ export type V2CredentialRemoveData = { path: { credentialID: string } - query?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } url: "/api/credential/{credentialID}" } @@ -15352,7 +15285,12 @@ export type V2CredentialUpdateData = { path: { credentialID: string } - query?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } url: "/api/credential/{credentialID}" } @@ -15777,6 +15715,315 @@ export type V2EventSubscribeResponses = { export type V2EventSubscribeResponse = V2EventSubscribeResponses[keyof V2EventSubscribeResponses] +export type V2PtyListData = { + body?: never + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty" +} + +export type V2PtyListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PtyListError = V2PtyListErrors[keyof V2PtyListErrors] + +export type V2PtyListResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Array + } +} + +export type V2PtyListResponse = V2PtyListResponses[keyof V2PtyListResponses] + +export type V2PtyCreateData = { + body: { + command?: string + args?: Array + cwd?: string + title?: string + env?: { + [key: string]: string + } + } + path?: never + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty" +} + +export type V2PtyCreateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError +} + +export type V2PtyCreateError = V2PtyCreateErrors[keyof V2PtyCreateErrors] + +export type V2PtyCreateResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyCreateResponse = V2PtyCreateResponses[keyof V2PtyCreateResponses] + +export type V2PtyRemoveData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyRemoveError = V2PtyRemoveErrors[keyof V2PtyRemoveErrors] + +export type V2PtyRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2PtyRemoveResponse = V2PtyRemoveResponses[keyof V2PtyRemoveResponses] + +export type V2PtyGetData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyGetErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyGetError = V2PtyGetErrors[keyof V2PtyGetErrors] + +export type V2PtyGetResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyGetResponse = V2PtyGetResponses[keyof V2PtyGetResponses] + +export type V2PtyUpdateData = { + body: { + title?: string + sessionID?: string + size?: { + rows: number + cols: number + } + } + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}" +} + +export type V2PtyUpdateErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyUpdateError = V2PtyUpdateErrors[keyof V2PtyUpdateErrors] + +export type V2PtyUpdateResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: Pty + } +} + +export type V2PtyUpdateResponse = V2PtyUpdateResponses[keyof V2PtyUpdateResponses] + +export type V2PtyConnectTokenData = { + body?: never + path: { + ptyID: string + } + query?: { + location?: { + directory?: string + workspace?: string + } + } + url: "/api/pty/{ptyID}/connect-token" +} + +export type V2PtyConnectTokenErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ForbiddenError + */ + 403: ForbiddenError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyConnectTokenError = V2PtyConnectTokenErrors[keyof V2PtyConnectTokenErrors] + +export type V2PtyConnectTokenResponses = { + /** + * Success + */ + 200: { + location: LocationInfo + data: { + ticket: string + expires_in: number + } + } +} + +export type V2PtyConnectTokenResponse = V2PtyConnectTokenResponses[keyof V2PtyConnectTokenResponses] + +export type V2PtyConnectData = { + body?: never + path: { + ptyID: string + } + query?: { + "location[directory]"?: string + "location[workspace]"?: string + cursor?: string + ticket?: string + } + url: "/api/pty/{ptyID}/connect" +} + +export type V2PtyConnectErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * ForbiddenError + */ + 403: ForbiddenError + /** + * PtyNotFoundError + */ + 404: PtyNotFoundError +} + +export type V2PtyConnectError = V2PtyConnectErrors[keyof V2PtyConnectErrors] + +export type V2PtyConnectResponses = { + /** + * Success + */ + 200: boolean +} + +export type V2PtyConnectResponse = V2PtyConnectResponses[keyof V2PtyConnectResponses] + export type V2QuestionRequestListData = { body?: never path?: never diff --git a/packages/sdk/js/sst-env.d.ts b/packages/sdk/js/sst-env.d.ts new file mode 100644 index 0000000000..301538ccb2 --- /dev/null +++ b/packages/sdk/js/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index f0171b99d9..40139efb7f 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -881,6 +881,60 @@ ] } }, + "/experimental/capabilities": { + "get": { + "tags": ["experimental"], + "operationId": "experimental.capabilities.get", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "Experimental capabilities", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExperimentalCapabilities" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "description": "Get experimental features enabled on the Kilo server.", + "summary": "Get experimental capabilities", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.experimental.capabilities.get({\n ...\n})" + } + ] + } + }, "/experimental/console": { "get": { "tags": ["experimental"], @@ -22043,6 +22097,25 @@ "type": "string" }, "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -22108,6 +22181,25 @@ "type": "string" }, "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true } ], "security": [], @@ -23031,6 +23123,726 @@ ] } }, + "/api/pty": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.list", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pty" + } + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "List PTY sessions for a location, including exited sessions retained until removal.", + "summary": "List PTY sessions", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.list({\n ...\n})" + } + ] + }, + "post": { + "tags": ["pty"], + "operationId": "v2.pty.create", + "parameters": [ + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + } + }, + "description": "Create a pseudo-terminal session for a location.", + "summary": "Create PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "title": { + "type": "string" + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.create({\n ...\n})" + } + ] + } + }, + "/api/pty/{ptyID}": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.get", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Get one PTY session, including its exit code once exited.", + "summary": "Get PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.get({\n ...\n})" + } + ] + }, + "put": { + "tags": ["pty"], + "operationId": "v2.pty.update", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "$ref": "#/components/schemas/Pty" + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Update the title or viewport size of one PTY session.", + "summary": "Update PTY session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "sessionID": { + "type": "string", + "pattern": "^ses" + }, + "size": { + "type": "object", + "properties": { + "rows": { + "type": "integer", + "exclusiveMinimum": 0 + }, + "cols": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "required": true + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.update({\n ...\n})" + } + ] + }, + "delete": { + "tags": ["pty"], + "operationId": "v2.pty.remove", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Terminate and remove one PTY session.", + "summary": "Remove PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.remove({\n ...\n})" + } + ] + } + }, + "/api/pty/{ptyID}/connect-token": { + "post": { + "tags": ["pty"], + "operationId": "v2.pty.connectToken", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, + { + "name": "location", + "in": "query", + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "workspace": { + "type": "string" + } + }, + "additionalProperties": false + }, + "required": false, + "style": "deepObject", + "explode": true + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "location": { + "$ref": "#/components/schemas/LocationInfo" + }, + "data": { + "type": "object", + "properties": { + "ticket": { + "type": "string" + }, + "expires_in": { + "type": "integer", + "exclusiveMinimum": 0 + } + }, + "required": ["ticket", "expires_in"], + "additionalProperties": false + } + }, + "required": ["location", "data"], + "additionalProperties": false + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + "summary": "Create PTY WebSocket token", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.connectToken({\n ...\n})" + } + ] + } + }, + "/api/pty/{ptyID}/connect": { + "get": { + "tags": ["pty"], + "operationId": "v2.pty.connect", + "parameters": [ + { + "name": "ptyID", + "in": "path", + "schema": { + "type": "string", + "pattern": "^pty.*" + }, + "required": true + }, + { + "in": "query", + "name": "location[directory]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "location[workspace]", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "cursor", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "ticket", + "schema": { + "type": "string" + } + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "InvalidRequestError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidRequestError" + } + } + } + }, + "401": { + "description": "UnauthorizedError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedError" + } + } + } + }, + "403": { + "description": "ForbiddenError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForbiddenError" + } + } + } + }, + "404": { + "description": "PtyNotFoundError", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PtyNotFoundError" + } + } + } + } + }, + "description": "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + "summary": "Connect to PTY session", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\"\n\nconst client = createKiloClient()\nawait client.v2.pty.connect({\n ...\n})" + } + ] + } + }, "/api/question/request": { "get": { "tags": ["session questions"], @@ -23874,7 +24686,10 @@ "$ref": "#/components/schemas/EventPluginAdded" }, { - "$ref": "#/components/schemas/EventCatalogModelUpdated" + "$ref": "#/components/schemas/EventIntegrationUpdated" + }, + { + "$ref": "#/components/schemas/EventCatalogUpdated" }, { "$ref": "#/components/schemas/EventSessionCreated" @@ -24044,9 +24859,6 @@ { "$ref": "#/components/schemas/EventReferenceUpdated" }, - { - "$ref": "#/components/schemas/EventIntegrationUpdated" - }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -26824,6 +27636,10 @@ "type": "integer", "minimum": 0 }, + "exitCode": { + "type": "integer", + "minimum": 0 + }, "sessionID": { "anyOf": [ { @@ -27063,7 +27879,10 @@ "$ref": "#/components/schemas/EventPluginAdded" }, { - "$ref": "#/components/schemas/EventCatalogModelUpdated" + "$ref": "#/components/schemas/EventIntegrationUpdated" + }, + { + "$ref": "#/components/schemas/EventCatalogUpdated" }, { "$ref": "#/components/schemas/EventSessionCreated" @@ -27233,9 +28052,6 @@ { "$ref": "#/components/schemas/EventReferenceUpdated" }, - { - "$ref": "#/components/schemas/EventIntegrationUpdated" - }, { "$ref": "#/components/schemas/EventPermissionV2Asked" }, @@ -29245,6 +30061,16 @@ "required": ["id", "name", "source", "env", "options", "models"], "additionalProperties": false }, + "ExperimentalCapabilities": { + "type": "object", + "properties": { + "backgroundSubagents": { + "type": "boolean" + } + }, + "required": ["backgroundSubagents"], + "additionalProperties": false + }, "ConsoleState": { "type": "object", "properties": { @@ -34434,6 +35260,20 @@ "required": ["_tag", "providerID", "message"], "additionalProperties": false }, + "ForbiddenError": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": ["ForbiddenError"] + }, + "message": { + "type": "string" + } + }, + "required": ["_tag", "message"], + "additionalProperties": false + }, "ProjectCopyError": { "type": "object", "properties": { @@ -36440,597 +37280,7 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "ModelV2Info": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" - }, - "name": { - "type": "string" - }, - "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "settings"], - "additionalProperties": false - } - ] - }, - "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false - }, - "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": ["headers", "body"], - "additionalProperties": false - }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - } - }, - "required": ["id", "headers", "body"], - "additionalProperties": false - } - }, - "time": { - "type": "object", - "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - }, - { - "type": "string", - "enum": ["Infinity", "-Infinity", "NaN"] - } - ] - } - }, - "required": ["released"], - "additionalProperties": false - }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "integer" - }, - "input": { - "type": "integer" - }, - "output": { - "type": "integer" - } - }, - "required": ["context", "output"], - "additionalProperties": false - } - }, - "required": [ - "id", - "providerID", - "name", - "api", - "capabilities", - "request", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], - "additionalProperties": false - }, - "EventCatalogModelUpdated": { + "EventIntegrationUpdated": { "type": "object", "properties": { "id": { @@ -37038,17 +37288,29 @@ }, "type": { "type": "string", - "enum": ["catalog.model.updated"] + "enum": ["integration.updated"] }, "properties": { "type": "object", - "properties": { - "model": { - "$ref": "#/components/schemas/ModelV2Info" - } - }, - "required": ["model"], - "additionalProperties": false + "properties": {} + } + }, + "required": ["id", "type", "properties"], + "additionalProperties": false + }, + "EventCatalogUpdated": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["catalog.updated"] + }, + "properties": { + "type": "object", + "properties": {} } }, "required": ["id", "type", "properties"], @@ -39159,24 +39421,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "EventIntegrationUpdated": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["integration.updated"] - }, - "properties": { - "type": "object", - "properties": {} - } - }, - "required": ["id", "type", "properties"], - "additionalProperties": false - }, "PermissionV2Source": { "type": "object", "properties": { @@ -43046,6 +43290,596 @@ } ] }, + "ModelV2Info": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerID": { + "type": "string" + }, + "family": { + "type": "string" + }, + "name": { + "type": "string" + }, + "api": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["aisdk"] + }, + "package": { + "type": "string" + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "package"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["native"] + }, + "url": { + "type": "string" + }, + "settings": { + "type": "object" + } + }, + "required": ["id", "type", "settings"], + "additionalProperties": false + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "tools": { + "type": "boolean" + }, + "input": { + "type": "array", + "items": { + "type": "string" + } + }, + "output": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["tools", "input", "output"], + "additionalProperties": false + }, + "request": { + "type": "object", + "properties": { + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" + }, + "variant": { + "type": "string" + } + }, + "required": ["headers", "body"], + "additionalProperties": false + }, + "variants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "object" + }, + "generation": { + "type": "object", + "properties": { + "maxTokens": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topP": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "topK": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "frequencyPenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "presencePenalty": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "seed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + }, + "stop": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "options": { + "type": "object" + } + }, + "required": ["id", "headers", "body"], + "additionalProperties": false + } + }, + "time": { + "type": "object", + "properties": { + "released": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": ["NaN"] + }, + { + "type": "string", + "enum": ["Infinity"] + }, + { + "type": "string", + "enum": ["-Infinity"] + }, + { + "type": "string", + "enum": ["Infinity", "-Infinity", "NaN"] + } + ] + } + }, + "required": ["released"], + "additionalProperties": false + }, + "cost": { + "type": "array", + "items": { + "type": "object", + "properties": { + "tier": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["context"] + }, + "size": { + "type": "integer" + } + }, + "required": ["type", "size"], + "additionalProperties": false + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache": { + "type": "object", + "properties": { + "read": { + "type": "number" + }, + "write": { + "type": "number" + } + }, + "required": ["read", "write"], + "additionalProperties": false + } + }, + "required": ["input", "output", "cache"], + "additionalProperties": false + } + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated", "active"] + }, + "enabled": { + "type": "boolean" + }, + "limit": { + "type": "object", + "properties": { + "context": { + "type": "integer" + }, + "input": { + "type": "integer" + }, + "output": { + "type": "integer" + } + }, + "required": ["context", "output"], + "additionalProperties": false + } + }, + "required": [ + "id", + "providerID", + "name", + "api", + "capabilities", + "request", + "variants", + "time", + "cost", + "status", + "enabled", + "limit" + ], + "additionalProperties": false + }, "ProviderV2Info": { "type": "object", "properties": { @@ -43055,61 +43889,8 @@ "name": { "type": "string" }, - "enabled": { - "anyOf": [ - { - "type": "boolean", - "enum": [false] - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["env"] - }, - "name": { - "type": "string" - } - }, - "required": ["via", "name"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["credential"] - }, - "credentialID": { - "type": "string" - } - }, - "required": ["via", "credentialID"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "via": { - "type": "string", - "enum": ["custom"] - }, - "data": { - "type": "object" - } - }, - "required": ["via", "data"], - "additionalProperties": false - } - ] - }, - "env": { - "type": "array", - "items": { - "type": "string" - } + "disabled": { + "type": "boolean" }, "api": { "anyOf": [ @@ -43169,7 +43950,7 @@ "additionalProperties": false } }, - "required": ["id", "name", "enabled", "env", "api", "request"], + "required": ["id", "name", "api", "request"], "additionalProperties": false }, "IntegrationWhen": { @@ -44611,536 +45392,6 @@ "required": ["id", "type", "properties"], "additionalProperties": false }, - "ModelV2Info1": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "providerID": { - "type": "string" - }, - "family": { - "type": "string" - }, - "name": { - "type": "string" - }, - "api": { - "anyOf": [ - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["aisdk"] - }, - "package": { - "type": "string" - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "package"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "type": "string", - "enum": ["native"] - }, - "url": { - "type": "string" - }, - "settings": { - "type": "object" - } - }, - "required": ["id", "type", "settings"], - "additionalProperties": false - } - ] - }, - "capabilities": { - "type": "object", - "properties": { - "tools": { - "type": "boolean" - }, - "input": { - "type": "array", - "items": { - "type": "string" - } - }, - "output": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["tools", "input", "output"], - "additionalProperties": false - }, - "request": { - "type": "object", - "properties": { - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - }, - "variant": { - "type": "string" - } - }, - "required": ["headers", "body"], - "additionalProperties": false - }, - "variants": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "type": "object" - }, - "generation": { - "type": "object", - "properties": { - "maxTokens": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "temperature": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "topP": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "topK": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "frequencyPenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "presencePenalty": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "seed": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - }, - "stop": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - }, - "options": { - "type": "object" - } - }, - "required": ["id", "headers", "body"], - "additionalProperties": false - } - }, - "time": { - "type": "object", - "properties": { - "released": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "enum": ["NaN"] - }, - { - "type": "string", - "enum": ["Infinity"] - }, - { - "type": "string", - "enum": ["-Infinity"] - } - ] - } - }, - "required": ["released"], - "additionalProperties": false - }, - "cost": { - "type": "array", - "items": { - "type": "object", - "properties": { - "tier": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": ["context"] - }, - "size": { - "type": "integer" - } - }, - "required": ["type", "size"], - "additionalProperties": false - }, - "input": { - "type": "number" - }, - "output": { - "type": "number" - }, - "cache": { - "type": "object", - "properties": { - "read": { - "type": "number" - }, - "write": { - "type": "number" - } - }, - "required": ["read", "write"], - "additionalProperties": false - } - }, - "required": ["input", "output", "cache"], - "additionalProperties": false - } - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated", "active"] - }, - "enabled": { - "type": "boolean" - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "integer" - }, - "input": { - "type": "integer" - }, - "output": { - "type": "integer" - } - }, - "required": ["context", "output"], - "additionalProperties": false - } - }, - "required": [ - "id", - "providerID", - "name", - "api", - "capabilities", - "request", - "variants", - "time", - "cost", - "status", - "enabled", - "limit" - ], - "additionalProperties": false - }, "EventTuiToastShow1": { "type": "object", "properties": { @@ -45411,6 +45662,10 @@ "name": "events", "description": "Experimental event stream route." }, + { + "name": "pty", + "description": "Experimental location-scoped PTY routes." + }, { "name": "session questions", "description": "Experimental session question routes." diff --git a/packages/server/src/api.ts b/packages/server/src/api.ts index ac05355106..100a7246d3 100644 --- a/packages/server/src/api.ts +++ b/packages/server/src/api.ts @@ -11,6 +11,7 @@ import { SkillGroup } from "./groups/skill" import { EventGroup } from "./groups/event" import { AgentGroup } from "./groups/agent" import { HealthGroup } from "./groups/health" +import { PtyGroup } from "./groups/pty" import { QuestionGroup } from "./groups/question" import { ReferenceGroup } from "./groups/reference" import { Authorization } from "./middleware/authorization" @@ -34,6 +35,7 @@ export const Api = HttpApi.make("server") .add(CommandGroup) .add(SkillGroup) .add(EventGroup) + .add(PtyGroup) .add(QuestionGroup) .add(ReferenceGroup) .add(ProjectCopyGroup) diff --git a/packages/opencode/src/server/cors.ts b/packages/server/src/cors.ts similarity index 87% rename from packages/opencode/src/server/cors.ts rename to packages/server/src/cors.ts index 66348b8f52..3525855773 100644 --- a/packages/opencode/src/server/cors.ts +++ b/packages/server/src/cors.ts @@ -1,5 +1,5 @@ -import * as KiloServer from "@/kilocode/server/server" // kilocode_change import { Context } from "effect" +import { corsOrigin } from "./kilocode/cors" // kilocode_change const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ @@ -17,9 +17,7 @@ export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOption if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost") return true if (opencodeOrigin.test(input)) return true - // kilocode_change start - if (KiloServer.corsOrigin(input)) return true - // kilocode_change end + if (corsOrigin(input)) return true // kilocode_change return opts?.cors?.includes(input) ?? false } diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index 2b1dcaf116..2cf1eea583 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -84,3 +84,18 @@ export class QuestionNotFoundError extends Schema.TaggedErrorClass()( + "ForbiddenError", + { message: Schema.String }, + { httpApiStatus: 403 }, +) {} + +export class PtyNotFoundError extends Schema.TaggedErrorClass()( + "PtyNotFoundError", + { + ptyID: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} diff --git a/packages/server/src/groups/credential.ts b/packages/server/src/groups/credential.ts index 648553a3e0..b6e21ca30b 100644 --- a/packages/server/src/groups/credential.ts +++ b/packages/server/src/groups/credential.ts @@ -1,30 +1,38 @@ import { Credential } from "@opencode-ai/core/credential" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { LocationMiddleware, LocationQuery, locationQueryOpenApi } from "./location" export const CredentialGroup = HttpApiGroup.make("server.credential") .add( HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, + query: LocationQuery, payload: Schema.Struct({ label: Schema.String }), success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.update", - summary: "Update credential", - description: "Update a stored credential label.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.update", + summary: "Update credential", + description: "Update a stored credential label.", + }), + ), ) .add( HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", { params: { credentialID: Credential.ID }, + query: LocationQuery, success: HttpApiSchema.NoContent, - }).annotateMerge( - OpenApi.annotations({ - identifier: "v2.credential.remove", - summary: "Remove credential", - description: "Remove a stored integration credential.", - }), - ), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.credential.remove", + summary: "Remove credential", + description: "Remove a stored integration credential.", + }), + ), ) + .middleware(LocationMiddleware) diff --git a/packages/server/src/groups/pty.ts b/packages/server/src/groups/pty.ts new file mode 100644 index 0000000000..1c07a3e32f --- /dev/null +++ b/packages/server/src/groups/pty.ts @@ -0,0 +1,144 @@ +import { Pty } from "@opencode-ai/core/pty" +import { PtyID } from "@opencode-ai/core/pty/schema" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { Location } from "@opencode-ai/core/location" +import { Schema } from "effect" +import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" +import { ForbiddenError, PtyNotFoundError } from "../errors" +import { LocationQuery, locationQueryOpenApi, LocationMiddleware } from "./location" + +export const PTY_CONNECT_TICKET_QUERY = "ticket" +export const PTY_CONNECT_TOKEN_HEADER = "x-kilo-ticket" +export const PTY_CONNECT_TOKEN_HEADER_VALUE = "1" + +const PTY_CONNECT_PATH = /^\/api\/pty\/[^/]+\/connect$/ + +// Authorization middleware skips credential checks when this matches; the PTY connect handler +// is then responsible for consuming and validating the ticket. +export function hasPtyConnectTicketURL(url: URL) { + return PTY_CONNECT_PATH.test(url.pathname) && !!url.searchParams.get(PTY_CONNECT_TICKET_QUERY) +} + +export const PtyGroup = HttpApiGroup.make("server.pty") + .add( + HttpApiEndpoint.get("pty.list", "/api/pty", { + query: LocationQuery, + success: Location.response(Schema.Array(Pty.Info)), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.list", + summary: "List PTY sessions", + description: "List PTY sessions for a location, including exited sessions retained until removal.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.create", "/api/pty", { + query: LocationQuery, + payload: Pty.CreateInput, + success: Location.response(Pty.Info), + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.create", + summary: "Create PTY session", + description: "Create a pseudo-terminal session for a location.", + }), + ), + ) + .add( + HttpApiEndpoint.get("pty.get", "/api/pty/:ptyID", { + params: { ptyID: PtyID }, + query: LocationQuery, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.get", + summary: "Get PTY session", + description: "Get one PTY session, including its exit code once exited.", + }), + ), + ) + .add( + HttpApiEndpoint.put("pty.update", "/api/pty/:ptyID", { + params: { ptyID: PtyID }, + query: LocationQuery, + payload: Pty.UpdateInput, + success: Location.response(Pty.Info), + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.update", + summary: "Update PTY session", + description: "Update the title or viewport size of one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.delete("pty.remove", "/api/pty/:ptyID", { + params: { ptyID: PtyID }, + query: LocationQuery, + success: HttpApiSchema.NoContent, + error: PtyNotFoundError, + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.remove", + summary: "Remove PTY session", + description: "Terminate and remove one PTY session.", + }), + ), + ) + .add( + HttpApiEndpoint.post("pty.connectToken", "/api/pty/:ptyID/connect-token", { + params: { ptyID: PtyID }, + query: LocationQuery, + success: Location.response(PtyTicket.ConnectToken), + error: [ForbiddenError, PtyNotFoundError], + }) + .annotateMerge(locationQueryOpenApi) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connectToken", + summary: "Create PTY WebSocket token", + description: "Create a short-lived single-use ticket for opening a PTY WebSocket connection.", + }), + ), + ) + .add( + // Query fields are decoded in the raw handler after the existence check so a missing + // session responds with an empty 404 before any upgrade work. + HttpApiEndpoint.get("pty.connect", "/api/pty/:ptyID/connect", { + params: { ptyID: PtyID }, + success: Schema.Boolean, + error: [ForbiddenError, PtyNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "v2.pty.connect", + summary: "Connect to PTY session", + description: "Establish a WebSocket connection streaming PTY output and accepting terminal input.", + transform: (operation) => ({ + ...operation, + parameters: [ + ...(operation.parameters ?? []), + ...["location[directory]", "location[workspace]", "cursor", PTY_CONNECT_TICKET_QUERY].map((name) => ({ + in: "query", + name, + schema: { type: "string" }, + })), + ], + }), + }), + ), + ) + .annotateMerge(OpenApi.annotations({ title: "pty", description: "Experimental location-scoped PTY routes." })) + .middleware(LocationMiddleware) diff --git a/packages/server/src/handlers.ts b/packages/server/src/handlers.ts index c4335eca82..1d12dde550 100644 --- a/packages/server/src/handlers.ts +++ b/packages/server/src/handlers.ts @@ -1,5 +1,6 @@ import { SessionV2 } from "@opencode-ai/core/session" import { PermissionSaved } from "@opencode-ai/core/permission/saved" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" import { Layer } from "effect" import { layer as locationLayer } from "./groups/location" import { sessionLocationLayer } from "./middleware/session-location" @@ -14,6 +15,7 @@ import { SkillHandler } from "./handlers/skill" import { EventHandler } from "./handlers/event" import { AgentHandler } from "./handlers/agent" import { HealthHandler } from "./handlers/health" +import { PtyHandler } from "./handlers/pty" import { QuestionHandler } from "./handlers/question" import { ReferenceHandler } from "./handlers/reference" import * as SessionExecutionLocal from "@opencode-ai/core/session/execution/local" @@ -38,6 +40,7 @@ export const handlers = Layer.mergeAll( CommandHandler, SkillHandler, EventHandler, + PtyHandler, QuestionHandler, ReferenceHandler, ProjectCopyHandler, @@ -47,6 +50,7 @@ export const handlers = Layer.mergeAll( Layer.provide(SessionV2.defaultLayer), Layer.provide(SessionExecutionLocal.defaultLayer), Layer.provide(PermissionSaved.defaultLayer), + Layer.provide(PtyTicket.defaultLayer), // kilocode_change - the host provides LocationServiceMap so Kilo can install effective-reference initialization Layer.provide(Credential.defaultLayer), ) diff --git a/packages/server/src/handlers/credential.ts b/packages/server/src/handlers/credential.ts index 7203060f28..7e138a5d5a 100644 --- a/packages/server/src/handlers/credential.ts +++ b/packages/server/src/handlers/credential.ts @@ -1,4 +1,4 @@ -import { Credential } from "@opencode-ai/core/credential" +import { Integration } from "@opencode-ai/core/integration" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" @@ -8,14 +8,14 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential", .handle( "credential.update", Effect.fn(function* (ctx) { - yield* (yield* Credential.Service).update(ctx.params.credentialID, { label: ctx.payload.label }) + yield* (yield* Integration.Service).connection.update(ctx.params.credentialID, { label: ctx.payload.label }) return HttpApiSchema.NoContent.make() }), ) .handle( "credential.remove", Effect.fn(function* (ctx) { - yield* (yield* Credential.Service).remove(ctx.params.credentialID) + yield* (yield* Integration.Service).connection.remove(ctx.params.credentialID) return HttpApiSchema.NoContent.make() }), ), diff --git a/packages/server/src/handlers/integration.ts b/packages/server/src/handlers/integration.ts index 70cfbfa68b..d7c651e847 100644 --- a/packages/server/src/handlers/integration.ts +++ b/packages/server/src/handlers/integration.ts @@ -38,7 +38,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" Effect.fn(function* (ctx) { const service = yield* Integration.Service yield* authorize( - service.connect.key({ + service.connection.key({ integrationID: ctx.params.integrationID, key: ctx.payload.key, label: ctx.payload.label, @@ -53,7 +53,7 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration" const service = yield* Integration.Service return yield* response( authorize( - service.connect.oauth({ + service.connection.oauth({ integrationID: ctx.params.integrationID, methodID: ctx.payload.methodID, inputs: ctx.payload.inputs, diff --git a/packages/server/src/handlers/pty.ts b/packages/server/src/handlers/pty.ts new file mode 100644 index 0000000000..a59afb3b31 --- /dev/null +++ b/packages/server/src/handlers/pty.ts @@ -0,0 +1,219 @@ +import { Pty } from "@opencode-ai/core/pty" +import { PtyProtocol } from "@opencode-ai/core/pty/protocol" +import { PtyTicket } from "@opencode-ai/core/pty/ticket" +import { Location } from "@opencode-ai/core/location" +import { Effect, Queue } from "effect" +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" +import * as Socket from "effect/unstable/socket/Socket" +import { Api } from "../api" +import { CorsConfig, isAllowedRequestOrigin } from "../cors" +import { ForbiddenError, PtyNotFoundError } from "../errors" +import { PTY_CONNECT_TICKET_QUERY, PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "../groups/pty" +import { response } from "../groups/location" +import { PtyEnvironment } from "../pty-environment" + +const ticketScope = Effect.gen(function* () { + const location = yield* Location.Service + return { directory: location.directory as string, workspaceID: location.workspaceID } +}) + +export const PtyHandler = HttpApiBuilder.group(Api, "server.pty", (handlers) => + Effect.gen(function* () { + const tickets = yield* PtyTicket.Service + const cors = yield* CorsConfig + const environment = yield* PtyEnvironment.Service + + return handlers + .handle( + "pty.list", + Effect.fn(function* () { + return yield* response((yield* Pty.Service).list()) + }), + ) + .handle( + "pty.create", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + const location = yield* Location.Service + const cwd = ctx.payload.cwd || location.directory + return yield* response( + pty.create({ + ...ctx.payload, + args: ctx.payload.args ? [...ctx.payload.args] : undefined, + cwd, + env: { + ...ctx.payload.env, + ...(yield* environment.get({ directory: location.directory, cwd })), + }, + }), + ) + }), + ) + .handle( + "pty.get", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + return yield* response( + pty.get(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ), + ) + }), + ) + .handle( + "pty.update", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + return yield* response( + pty + .update(ctx.params.ptyID, { + ...ctx.payload, + size: ctx.payload.size ? { ...ctx.payload.size } : undefined, + }) + .pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ), + ) + }), + ) + .handle( + "pty.remove", + Effect.fn(function* (ctx) { + const pty = yield* Pty.Service + yield* pty.remove(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "pty.connectToken", + Effect.fn(function* (ctx) { + const request = yield* HttpServerRequest.HttpServerRequest + // The custom header forces a CORS preflight, so cross-origin browser pages cannot + // mint tickets without passing the server's origin policy. + if ( + request.headers[PTY_CONNECT_TOKEN_HEADER] !== PTY_CONNECT_TOKEN_HEADER_VALUE || + !isAllowedRequestOrigin(request.headers.origin, request.headers.host, cors) + ) + return yield* new ForbiddenError({ message: "Invalid PTY connect token request" }) + const pty = yield* Pty.Service + yield* pty.get(ctx.params.ptyID).pipe( + Effect.catchTag( + "Pty.NotFoundError", + () => + new PtyNotFoundError({ + ptyID: ctx.params.ptyID, + message: `PTY session not found: ${ctx.params.ptyID}`, + }), + ), + ) + return yield* response(tickets.issue({ ptyID: ctx.params.ptyID, ...(yield* ticketScope) })) + }), + ) + .handleRaw( + "pty.connect", + Effect.fn("PtyHandler.connect")(function* (ctx) { + const pty = yield* Pty.Service + const exists = yield* pty.get(ctx.params.ptyID).pipe( + Effect.as(true), + Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)), + ) + if (!exists) return HttpServerResponse.empty({ status: 404 }) + + const url = new URL(ctx.request.url, "http://localhost") + const ticket = url.searchParams.get(PTY_CONNECT_TICKET_QUERY) + if (ticket) { + const valid = isAllowedRequestOrigin(ctx.request.headers.origin, ctx.request.headers.host, cors) + ? yield* tickets.consume({ ticket, ptyID: ctx.params.ptyID, ...(yield* ticketScope) }) + : false + if (!valid) return HttpServerResponse.empty({ status: 403 }) + } + const parsedCursor = url.searchParams.get("cursor") + const cursorNumber = parsedCursor === null ? undefined : Number(parsedCursor) + const cursor = + cursorNumber !== undefined && Number.isSafeInteger(cursorNumber) && cursorNumber >= -1 + ? cursorNumber + : undefined + + const socket = yield* Effect.orDie(ctx.request.upgrade) + const write = yield* socket.writer + const closeAccepted = (event: Socket.CloseEvent) => + socket + .runRaw(() => Effect.void, { onOpen: write(event).pipe(Effect.catch(() => Effect.void)) }) + .pipe( + Effect.timeout("1 second"), + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.catch(() => Effect.void), + ) + + // Outbound frames flow through one queue drained by a single writer so replay, live + // output, and the close frame keep their order. + // TODO: Integrate graceful-shutdown socket tracking before clients migrate to this route. + const outbox = yield* Queue.unbounded() + const attachment = yield* pty + .attach(ctx.params.ptyID, { + cursor, + onData: (chunk) => Queue.offerUnsafe(outbox, chunk), + onEnd: () => Queue.offerUnsafe(outbox, new Socket.CloseEvent(1000)), + }) + .pipe( + Effect.catchTags({ + "Pty.NotFoundError": () => + closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)), + "Pty.ExitedError": () => + closeAccepted(new Socket.CloseEvent(4404, "session exited")).pipe(Effect.as(undefined)), + }), + ) + if (!attachment) return HttpServerResponse.empty() + + for (const chunk of PtyProtocol.chunks(attachment.replay)) Queue.offerUnsafe(outbox, chunk) + Queue.offerUnsafe(outbox, PtyProtocol.metaFrame(attachment.cursor)) + attachment.activate() + + const drain = Effect.gen(function* () { + while (true) { + const item = yield* Queue.take(outbox) + yield* write(item) + if (item instanceof Socket.CloseEvent) return + } + }) + + yield* Effect.race( + drain, + socket.runRaw((message) => { + const decoded = PtyProtocol.decodeInput(message) + if (decoded !== undefined) attachment.write(decoded) + }), + ).pipe( + Effect.catchReason("SocketError", "SocketCloseError", () => Effect.void), + Effect.ensuring(Effect.sync(() => attachment.detach())), + Effect.orDie, + ) + return HttpServerResponse.empty() + }), + ) + }), +) diff --git a/packages/server/src/kilocode/cors.ts b/packages/server/src/kilocode/cors.ts new file mode 100644 index 0000000000..ba97a1fbbb --- /dev/null +++ b/packages/server/src/kilocode/cors.ts @@ -0,0 +1,5 @@ +const origin = /^https:\/\/([a-z0-9-]+\.)*kilo\.ai$/ + +export function corsOrigin(input: string) { + return origin.test(input) +} diff --git a/packages/server/src/middleware/authorization.ts b/packages/server/src/middleware/authorization.ts index edbc85bb4f..5a8dae205d 100644 --- a/packages/server/src/middleware/authorization.ts +++ b/packages/server/src/middleware/authorization.ts @@ -1,5 +1,6 @@ import { ServerAuth } from "../auth" import { UnauthorizedError } from "../errors" +import { hasPtyConnectTicketURL } from "../groups/pty" import { Effect, Encoding, Layer, Redacted } from "effect" import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -45,6 +46,9 @@ export const authorizationLayer = Layer.effect( return Authorization.of((effect) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest + // Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips + // credential checks here; the connect handler consumes and validates the ticket. + if (hasPtyConnectTicketURL(new URL(request.url, "http://localhost"))) return yield* effect const credential = yield* credentialFromRequest(request) if (ServerAuth.authorized(credential, config)) return yield* effect yield* HttpEffect.appendPreResponseHandler((_request, response) => diff --git a/packages/server/src/pty-environment.ts b/packages/server/src/pty-environment.ts new file mode 100644 index 0000000000..fe3a375cdc --- /dev/null +++ b/packages/server/src/pty-environment.ts @@ -0,0 +1,16 @@ +export * as PtyEnvironment from "./pty-environment" + +import { Context, Effect, Layer } from "effect" + +export interface Interface { + readonly get: (input: { directory: string; cwd: string }) => Effect.Effect> +} + +export class Service extends Context.Service()("@opencode/ServerPtyEnvironment") {} + +export const defaultLayer = Layer.succeed( + Service, + Service.of({ + get: () => Effect.succeed({}), + }), +) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 48c95094dc..2563c9a806 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -9,11 +9,13 @@ import { ServerAuth } from "./auth" import { handlers } from "./handlers" import { authorizationLayer } from "./middleware/authorization" import { schemaErrorLayer } from "./middleware/schema-error" +import { PtyEnvironment } from "./pty-environment" import { noop as referenceNoop } from "./kilocode/reference-reconciler" // kilocode_change export function createRoutes(password?: string) { return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers), + Layer.provide(PtyEnvironment.defaultLayer), Layer.provide(referenceNoop), // kilocode_change - standalone server has no Kilo config reconciler Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), diff --git a/packages/server/sst-env.d.ts b/packages/server/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/server/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/storybook/.storybook/main.ts b/packages/storybook/.storybook/main.ts index efe7fae5d3..2d1144a0aa 100644 --- a/packages/storybook/.storybook/main.ts +++ b/packages/storybook/.storybook/main.ts @@ -20,7 +20,7 @@ export default defineMain({ "@storybook/addon-a11y", "@storybook/addon-vitest", ], - stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + stories: ["../../ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", "../../app/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], async viteFinal(config) { const { mergeConfig, searchForWorkspaceRoot } = await import("vite") return mergeConfig(config, { diff --git a/packages/storybook/.storybook/mocks/app/context/permission.ts b/packages/storybook/.storybook/mocks/app/context/permission.ts index b6fb37d96b..bfae134a7f 100644 --- a/packages/storybook/.storybook/mocks/app/context/permission.ts +++ b/packages/storybook/.storybook/mocks/app/context/permission.ts @@ -12,6 +12,9 @@ export function usePermission() { isAutoAccepting(sessionID: string, directory?: string) { return accepted.has(key(sessionID, directory)) }, + isAutoAcceptingDirectory() { + return false + }, toggleAutoAccept(sessionID: string, directory?: string) { const next = key(sessionID, directory) if (accepted.has(next)) { diff --git a/packages/storybook/.storybook/mocks/app/context/prompt.ts b/packages/storybook/.storybook/mocks/app/context/prompt.ts index e5e0e5d335..1e38313b8c 100644 --- a/packages/storybook/.storybook/mocks/app/context/prompt.ts +++ b/packages/storybook/.storybook/mocks/app/context/prompt.ts @@ -1,4 +1,4 @@ -import { createSignal } from "solid-js" +import { createStore } from "solid-js/store" interface PartBase { content: string @@ -60,48 +60,50 @@ export function isPromptEqual(a: Prompt, b: Prompt) { return a.every((part, i) => JSON.stringify(part) === JSON.stringify(b[i])) } -let index = 0 -const [prompt, setPrompt] = createSignal(clonePrompt(DEFAULT_PROMPT)) -const [cursor, setCursor] = createSignal(0) -const [items, setItems] = createSignal([]) +export function createPromptState() { + const [store, setStore] = createStore({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: 0, + items: [] as ContextItem[], + }) + let index = 0 + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + const withKey = (item: Omit & { key?: string }): ContextItem => ({ + ...item, + key: item.key ?? `ctx:${++index}`, + }) -const withKey = (item: Omit & { key?: string }): ContextItem => ({ - ...item, - key: item.key ?? `ctx:${++index}`, -}) - -export function usePrompt() { return { - ready: () => true, - current: prompt, - cursor, - dirty: () => !isPromptEqual(prompt(), DEFAULT_PROMPT), + ready: () => ready, + current: () => store.prompt, + cursor: () => store.cursor, + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), set(next: Prompt, cursorPosition?: number) { - setPrompt(clonePrompt(next)) - if (cursorPosition !== undefined) setCursor(cursorPosition) + setStore("prompt", clonePrompt(next)) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) }, reset() { - setPrompt(clonePrompt(DEFAULT_PROMPT)) - setCursor(0) - setItems((current) => current.filter((item) => !!item.comment?.trim())) + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + setStore("items", (current) => current.filter((item) => !!item.comment?.trim())) }, context: { - items, + items: () => store.items, add(item: Omit & { key?: string }) { const next = withKey(item) - if (items().some((current) => current.key === next.key)) return - setItems((current) => [...current, next]) + if (store.items.some((current) => current.key === next.key)) return + setStore("items", (current) => [...current, next]) }, remove(key: string) { - setItems((current) => current.filter((item) => item.key !== key)) + setStore("items", (current) => current.filter((item) => item.key !== key)) }, removeComment(path: string, commentID: string) { - setItems((current) => + setStore("items", (current) => current.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), ) }, updateComment(path: string, commentID: string, next: Partial) { - setItems((current) => + setStore("items", (current) => current.map((item) => { if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item return withKey({ ...item, ...next }) @@ -109,9 +111,15 @@ export function usePrompt() { ) }, replaceComments(next: Array & { key?: string }>) { - const nonComment = items().filter((item) => !item.comment?.trim()) - setItems([...nonComment, ...next.map(withKey)]) + const nonComment = store.items.filter((item) => !item.comment?.trim()) + setStore("items", [...nonComment, ...next.map(withKey)]) }, }, } } + +const prompt = createPromptState() + +export function usePrompt() { + return prompt +} diff --git a/packages/storybook/.storybook/mocks/app/context/sdk.ts b/packages/storybook/.storybook/mocks/app/context/sdk.ts index c37d682496..749772bb3e 100644 --- a/packages/storybook/.storybook/mocks/app/context/sdk.ts +++ b/packages/storybook/.storybook/mocks/app/context/sdk.ts @@ -12,14 +12,16 @@ const make = (directory: string) => ({ }) const root = "/tmp/story" +const sdk = { + directory: root, + scope: "story-server", + url: "http://localhost:4096", + client: make(root), + createClient(input: { directory: string }) { + return make(input.directory) + }, +} export function useSDK() { - return { - directory: root, - url: "http://localhost:4096", - client: make(root), - createClient(input: { directory: string }) { - return make(input.directory) - }, - } + return () => sdk } diff --git a/packages/storybook/.storybook/mocks/app/context/sync.ts b/packages/storybook/.storybook/mocks/app/context/sync.ts index bfc49dc83a..6f554fd7dd 100644 --- a/packages/storybook/.storybook/mocks/app/context/sync.ts +++ b/packages/storybook/.storybook/mocks/app/context/sync.ts @@ -9,24 +9,27 @@ const [data, setData] = createStore({ "story-session": [] as Array<{ id: string; role: string }>, } as Record>, session_status: {} as Record, + session_working: () => false, agent: [{ name: "build", mode: "task", hidden: false }], command: [{ name: "fix", description: "Run fix command", source: "project" }], }) -export function useSync() { - return { - data, - set(...input: unknown[]) { - ;(setData as (...args: unknown[]) => void)(...input) +const sync = { + data, + set(...input: unknown[]) { + ;(setData as (...args: unknown[]) => void)(...input) + }, + session: { + get(id: string) { + return { id } }, - session: { - get(id: string) { - return { id } - }, - optimistic: { - add() {}, - remove() {}, - }, + optimistic: { + add() {}, + remove() {}, }, - } + }, +} + +export function useSync() { + return () => sync } diff --git a/packages/storybook/.storybook/mocks/solid-router.tsx b/packages/storybook/.storybook/mocks/solid-router.tsx index c1bda30f4b..6a2b53feb0 100644 --- a/packages/storybook/.storybook/mocks/solid-router.tsx +++ b/packages/storybook/.storybook/mocks/solid-router.tsx @@ -11,6 +11,10 @@ export function useNavigate() { return () => undefined } +export function useSearchParams>() { + return [{} as Partial, () => undefined] as const +} + export function useLocation() { return { pathname: "/story/session/story-session", diff --git a/packages/storybook/.storybook/preview.tsx b/packages/storybook/.storybook/preview.tsx index 4e28e43039..7b892f7683 100644 --- a/packages/storybook/.storybook/preview.tsx +++ b/packages/storybook/.storybook/preview.tsx @@ -1,4 +1,5 @@ import "@opencode-ai/ui/styles/tailwind" +import "@opencode-ai/ui/v2/styles/tailwind.css" import { createEffect, onCleanup, onMount } from "solid-js" import addonA11y from "@storybook/addon-a11y" diff --git a/packages/storybook/sst-env.d.ts b/packages/storybook/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/storybook/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/tui/package.json b/packages/tui/package.json index e0e563ed98..344179e4d0 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -7,8 +7,8 @@ "license": "MIT", "scripts": { "test": "bun test --timeout 30000 --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "exports": { ".": "./src/index.tsx", @@ -69,5 +69,6 @@ "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" - } + }, + "peerDependencies": {} } diff --git a/packages/tui/src/component/dialog-console-org.tsx b/packages/tui/src/component/dialog-console-org.tsx index b8c61a51c0..b571d63b47 100644 --- a/packages/tui/src/component/dialog-console-org.tsx +++ b/packages/tui/src/component/dialog-console-org.tsx @@ -1,9 +1,11 @@ -import { createResource, createMemo } from "solid-js" +import { createResource, createMemo, createSignal } from "solid-js" +import { TextAttributes } from "@opentui/core" import { DialogSelect } from "../ui/dialog-select" import { useSDK } from "../context/sdk" import { useDialog } from "../ui/dialog" import { useToast } from "../ui/toast" import { useTheme } from "../context/theme" +import { errorMessage } from "../util/error" import type { ExperimentalConsoleListOrgsResponse } from "@kilocode/sdk/v2" type OrgOption = ExperimentalConsoleListOrgsResponse["orgs"][number] @@ -25,14 +27,26 @@ export function DialogConsoleOrg() { const toast = useToast() const { theme } = useTheme() - const [orgs] = createResource(async () => { - const result = await sdk.client.experimental.console.listOrgs({}, { throwOnError: true }) - return result.data?.orgs ?? [] - }) + const [loadError, setLoadError] = createSignal() + + const [orgs] = createResource(() => + sdk.client.experimental.console + .listOrgs({}, { throwOnError: true }) + .then((result) => result.data?.orgs ?? []) + // Catch so the rejected resource never reaches the memos below: reading + // orgs() in an errored state re-throws and tears down the dialog. + .catch((error) => { + setLoadError(error) + return undefined + }), + ) + + const showError = createMemo(() => Boolean(loadError())) const current = createMemo(() => orgs()?.find((item) => item.active)) const options = createMemo(() => { + if (showError()) return [] const listed = orgs() if (listed === undefined) { return [ @@ -99,5 +113,23 @@ export function DialogConsoleOrg() { })) }) - return title="Switch org" options={options()} current={current()} /> + return ( + + title="Switch org" + options={options()} + current={current()} + renderFilter={!showError()} + locked={showError()} + emptyView={ + showError() ? ( + + + Could not load orgs + + {errorMessage(loadError())} + + ) : undefined + } + /> + ) } diff --git a/packages/tui/src/component/dialog-move-session.tsx b/packages/tui/src/component/dialog-move-session.tsx index 251c24ba26..2fb5824704 100644 --- a/packages/tui/src/component/dialog-move-session.tsx +++ b/packages/tui/src/component/dialog-move-session.tsx @@ -22,14 +22,16 @@ import { useRoute } from "../context/route" export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" } type ProjectDirectory = ProjectDirectories[number] -export function DialogMoveSession(props: { +type DialogMoveSessionProps = { projectID: string current?: MoveSessionSelection onSelect: (selection: MoveSessionSelection) => void onCurrentChange?: (selection: MoveSessionSelection) => void initialDirectories?: ProjectDirectory[] initialRemoving?: string -}) { +} + +export function DialogMoveSession(props: DialogMoveSessionProps) { const dialog = useDialog() const sdk = useSDK() const dimensions = useTerminalDimensions() @@ -43,62 +45,75 @@ export function DialogMoveSession(props: { const [toDelete, setToDelete] = createSignal() const [removing, setRemoving] = createSignal(props.initialRemoving) const [replacementCurrent, setReplacementCurrent] = createSignal() + const [loadError, setLoadError] = createSignal() const deleteHint = useCommandShortcut("dialog.move_session.delete") + onMount(() => dialog.setSize("xlarge")) function reopen(initialRemoving?: string) { dialog.replace(() => ( - + )) } + // A failed current-checkout lookup only affects which row is highlighted, so + // swallow it and let the directory list render without a current marker. const [loadedProject] = createResource( () => (projectContext.project() === props.projectID ? undefined : props.projectID), - async (projectID) => { - const result = await sdk.client.project.current({}, { throwOnError: true }) - return result.data?.id === projectID ? result.data.worktree : undefined - }, - ) - const currentCheckout = createMemo(() => - projectContext.project() === props.projectID ? projectContext.instance.path().worktree : loadedProject(), + (projectID) => + sdk.client.project + .current({}, { throwOnError: true }) + .then((result) => (result.data?.id === projectID ? result.data.worktree : undefined)) + .catch(() => undefined), ) + const currentCheckout = createMemo(() => { + if (projectContext.project() === props.projectID) return projectContext.instance.path().worktree + return loadedProject() + }) const [directories, { refetch }] = createResource( () => (props.initialRemoving ? undefined : props.projectID), - async (projectID) => { - setWorking(true) + async (projectID, info): Promise => { try { await sdk.client.v2.projectCopy.refresh( { projectID, location: { directory: sdk.directory } }, { throwOnError: true }, ) const directories = await sdk.client.project.directories({ projectID }, { throwOnError: true }) + setLoadError(undefined) return directories.data ?? [] - } finally { - setWorking(false) + } catch (error) { + setLoadError(error) + // An initial load with no data surfaces the inline error view below. A + // failed refresh intentionally stays quiet and keeps the already-shown + // list interactive; reopening the dialog retries the load. + return info.value } }, - { initialValue: props.initialDirectories }, ) + const directoryData = createMemo(() => directories() ?? props.initialDirectories) + // Show the locked error view only when we have nothing to display. A refresh + // that fails after the list rendered keeps the list and its actions. + const showError = createMemo(() => Boolean(loadError()) && !directoryData()) const currentDirectory = createMemo( () => replacementCurrent() ?? (props.current?.type === "directory" ? props.current.directory : currentCheckout()), ) const currentRoot = createMemo(() => { + if (showError()) return const directory = currentDirectory() if (!directory) return return ( - directories() + directoryData() ?.filter((root) => contains(root.directory, directory)) .toSorted((a, b) => b.directory.length - a.directory.length)[0] ?? { directory } ) }) const options = createMemo[]>(() => { - const data = directories() + if (showError()) return [] + const data = directoryData() const current = currentRoot()?.directory if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }] - if (directories.error && !data && !current) - return [{ title: "Failed to load project directories", value: undefined }] const roots = [...(data ?? [])] if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current }) roots.sort((a, b) => { @@ -194,7 +209,7 @@ export function DialogMoveSession(props: { async function remove(option: DialogSelectOption) { if (!option.value || option.value.type !== "directory" || option.value.subdirectory || removing()) return - const data = directories() + const data = directoryData() const selected = option.value const root = data?.find((item) => item.directory === selected.directory) if (!root?.strategy) return @@ -264,10 +279,12 @@ export function DialogMoveSession(props: { if (await removedCurrent(deletingCurrent)) return } - onMount(() => dialog.setSize("xlarge")) + const fullHeight = createMemo(() => + Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)), + ) return ( - + Move session - + } + renderFilter={!showError()} options={options()} - locked={directories.loading || loadedProject.loading || Boolean(removing())} + emptyView={ + showError() ? ( + + + Could not load project directories + + {errorMessage(loadError())} + + ) : undefined + } + locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())} current={current()} onSelect={(option) => { if (option.value) props.onSelect(option.value) }} onMove={() => setToDelete(undefined)} - actions={[ - { - command: "dialog.move_session.new", - title: "new", - onTrigger: () => props.onSelect({ type: "new" }), - }, - { - command: "dialog.move_session.delete", - title: "delete", - disabled: (option) => { - const value = option?.value - if (!value || value.type !== "directory" || value.subdirectory) return true - return !directories()?.find((item) => item.directory === value.directory)?.strategy - }, - onTrigger: remove, - }, - { - command: "dialog.move_session.refresh", - title: "refresh", - onTrigger: () => void refetch(), - }, - ]} + actions={ + showError() + ? [] + : [ + { + command: "dialog.move_session.new", + title: "new", + onTrigger: () => props.onSelect({ type: "new" }), + }, + { + command: "dialog.move_session.delete", + title: "delete", + disabled: (option) => { + const value = option?.value + if (!value || value.type !== "directory" || value.subdirectory) return true + return !directoryData()?.find((item) => item.directory === value.directory)?.strategy + }, + onTrigger: remove, + }, + { + command: "dialog.move_session.refresh", + title: "refresh", + onTrigger: () => void refetch(), + }, + ] + } /> ) diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index cc6d28306d..82df3d2f7d 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -391,7 +391,8 @@ export function Autocomplete(props: { const text = `${res.name} (${res.uri})` options.push({ display: Locale.truncateMiddle(text, width), - value: text, + // Match the name only; matching the URI caused unrelated fuzzy hits. + value: res.name, description: res.description, onSelect: () => { insertPart(res.name, { @@ -522,7 +523,8 @@ export function Autocomplete(props: { .go(removeLineRange(searchValue), nonFileOptions, { keys: [ (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()), - "description", + // Match description for slash commands only; for "@" it surfaced unrelated items. + ...(store.visible === "/" ? ["description" as const] : []), (obj) => obj.aliases?.join(" ") ?? "", ], limit: 10, diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 6fd6451245..d416108a5e 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -139,6 +139,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ ) => { // kilocode_change end switch (event.type) { + case "catalog.updated": + void Promise.all([ + result.location.model.refresh(eventLocation(metadata)), + result.location.provider.refresh(eventLocation(metadata)), + ]) + break case "session.next.agent.switched": message.update(event.properties.sessionID, (draft) => { message.prepend(draft, { @@ -445,7 +451,11 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ void result.location.reference.refresh() break case "integration.updated": - void result.location.integration.refresh(eventLocation(metadata)) // kilocode_change + void Promise.all([ + result.location.integration.refresh(eventLocation(metadata)), + result.location.model.refresh(eventLocation(metadata)), + result.location.provider.refresh(eventLocation(metadata)), + ]) break } } // kilocode_change diff --git a/packages/tui/src/context/sync.tsx b/packages/tui/src/context/sync.tsx index 84152ff81b..dbe7fca6cc 100644 --- a/packages/tui/src/context/sync.tsx +++ b/packages/tui/src/context/sync.tsx @@ -73,6 +73,9 @@ export const { provider_default: Record provider_next: ProviderListResponse console_state: ConsoleState + capabilities: { + experimentalBackgroundSubagents: boolean + } provider_auth: Record agent: Agent[] command: Command[] @@ -126,6 +129,9 @@ export const { failed: [], }, console_state: emptyConsoleState, + capabilities: { + experimentalBackgroundSubagents: false, + }, provider_auth: {}, config: {}, globalConfig: {}, // kilocode_change @@ -762,6 +768,10 @@ export const { // blocking - include session.list when continuing a session const providersPromise = sdk.client.config.providers({ workspace }, { throwOnError: true }) const providerListPromise = sdk.client.provider.list({ workspace }, { throwOnError: true }) + const capabilitiesPromise = sdk.client.experimental.capabilities + .get({ workspace }, { throwOnError: true }) + .then((x) => x.data) + .catch(() => undefined) const consoleStatePromise = sdk.client.experimental.console .get({ workspace }, { throwOnError: true }) .then((x) => x.data) @@ -772,6 +782,7 @@ export const { await Promise.all([ providersPromise, providerListPromise, + capabilitiesPromise, agentsPromise, configPromise, globalConfigPromise, // kilocode_change @@ -781,6 +792,7 @@ export const { .then(async () => { const providersResponse = providersPromise.then((x) => x.data!) const providerListResponse = providerListPromise.then((x) => x.data!) + const capabilitiesResponse = capabilitiesPromise const consoleStateResponse = consoleStatePromise const agentsResponse = agentsPromise.then((x) => x.data ?? []) const configResponse = configPromise.then((x) => x.data!) @@ -790,6 +802,7 @@ export const { return Promise.all([ providersResponse, providerListResponse, + capabilitiesResponse, consoleStateResponse, agentsResponse, configResponse, @@ -798,16 +811,18 @@ export const { ]).then((responses) => { const providers = responses[0] const providerList = responses[1] - const consoleState = responses[2] - const agents = responses[3] - const config = responses[4] - const globalConfig = responses[5] // kilocode_change - const sessions = responses[6] + const capabilities = responses[2] + const consoleState = responses[3] + const agents = responses[4] + const config = responses[5] + const globalConfig = responses[6] // kilocode_change + const sessions = responses[7] batch(() => { setStore("provider", reconcile(providers.providers)) setStore("provider_default", reconcile(providers.default)) setStore("provider_next", reconcile(providerList)) + setStore("capabilities", "experimentalBackgroundSubagents", capabilities?.backgroundSubagents === true) setStore("console_state", reconcile(consoleState)) setStore("agent", reconcile(agents)) setStore("config", reconcile(config)) diff --git a/packages/tui/src/kilocode/session-mentions.ts b/packages/tui/src/kilocode/session-mentions.ts index 55d145f3c4..92a47031af 100644 --- a/packages/tui/src/kilocode/session-mentions.ts +++ b/packages/tui/src/kilocode/session-mentions.ts @@ -12,7 +12,6 @@ export type SessionMention = { title: string updated: number } - export async function fetchSessionMentions( sdk: ReturnType, directory: string, @@ -81,4 +80,3 @@ export function createSessionPart(session: SessionMention) { }, } } - diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 13d11f66cf..fc1d28b8d7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -106,6 +106,8 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_don const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"]) +export const alwaysSeparate = new WeakSet() + type RetryAction = Extract["action"] function goUpsellKeys(action: RetryAction) { @@ -179,7 +181,6 @@ const context = createContext<{ showTimestamps: () => boolean showDetails: () => boolean showGenericToolOutput: () => boolean - userMessageIDs: () => ReadonlySet diffWrapMode: () => "word" | "none" providers: () => ReadonlyMap sync: ReturnType @@ -225,23 +226,17 @@ export function Session() { }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const foregroundTasks = createMemo(() => - messages().flatMap((message) => - (sync.data.part[message.id] ?? []).filter( - (part): part is ToolPart => - part.type === "tool" && - part.tool === "task" && - part.state.status === "running" && - part.state.metadata?.background !== true, - ), - ), - ) - const userMessageIDs = createMemo( - () => - new Set( - messages() - .filter((message) => message.role === "user") - .map((message) => message.id), - ), + sync.data.capabilities.experimentalBackgroundSubagents + ? messages().flatMap((message) => + (sync.data.part[message.id] ?? []).filter( + (part): part is ToolPart => + part.type === "tool" && + part.tool === "task" && + part.state.status === "running" && + part.state.metadata?.background !== true, + ), + ) + : [], ) const permissions = createMemo(() => { if (session()?.parentID) return [] @@ -1290,7 +1285,6 @@ export function Session() { showTimestamps, showDetails, showGenericToolOutput, - userMessageIDs, diffWrapMode, providers, sync, @@ -1552,6 +1546,7 @@ function UserMessage(props: { alwaysSeparate.add(el)} border={["left"]} borderColor={color()} customBorderChars={SplitBorder.customBorderChars} @@ -1679,13 +1674,16 @@ function AssistantMessage(props: { {childShortcut()} view subagents - x.type === "tool" && - x.tool === "task" && - x.state.status === "running" && - x.state.metadata?.background !== true, - )} + when={ + sync.data.capabilities.experimentalBackgroundSubagents && + props.parts.some( + (x) => + x.type === "tool" && + x.tool === "task" && + x.state.status === "running" && + x.state.metadata?.background !== true, + ) + } > · {backgroundShortcut()} @@ -1700,7 +1698,7 @@ function AssistantMessage(props: { error={props.message.error!} fallback={ alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} @@ -1718,7 +1716,7 @@ function AssistantMessage(props: { {/* kilocode_change end */} - + alwaysSeparate.add(el)} paddingLeft={3}> ) -} // kilocode_change +} // kilocode_change start - register rendered step-finish parts const PART_MAPPING = { @@ -1760,7 +1758,7 @@ const PART_MAPPING = { } // kilocode_change end -const INLINE_TOOL_ICON_WIDTH = 2 // kilocode_change +const INLINE_TOOL_ICON_WIDTH = 2 // kilocode_change start - show concrete routed models reported by gateway/provider responses function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: AssistantMessage }) { @@ -1817,7 +1815,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass return ( alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexDirection="column" @@ -1909,7 +1907,7 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess // kilocode_change end return ( - + alwaysSeparate.add(el)} paddingLeft={3} marginTop={1} flexShrink={0}> void @@ -2217,7 +2215,6 @@ function InlineTool(props: { return ( id !== undefined && ctx.userMessageIDs().has(id)} + separate={props.separate} onMouseOver={() => clickable() && setHover(true)} onMouseOut={() => setHover(false)} onMouseUp={() => { @@ -2249,7 +2245,6 @@ function InlineTool(props: { } export function InlineToolRow(props: { - id?: string icon: string iconColor?: RGBA color?: RGBA @@ -2262,30 +2257,23 @@ export function InlineToolRow(props: { pending: string failure?: string spinner?: boolean - subagent?: boolean + separate?: boolean children: JSX.Element - separateAfter?: (id: string | undefined) => boolean onMouseOver?: () => void onMouseOut?: () => void onMouseUp?: () => void }) { return ( { + if (props.separate) alwaysSeparate.add(el) setPreLayoutSiblingMargin(el, (previous) => { - const previousInline = previous?.id.startsWith("tool-inline-") ?? false - const previousSubagent = previous?.id.startsWith("tool-inline-subagent-") ?? false - return previous?.id.startsWith("text-") || - previous?.id.startsWith("tool-block-") || - previous?.id.startsWith("assistant-error-") || - previous?.id.startsWith("assistant-summary-") || - (previousInline && previousSubagent !== Boolean(props.subagent)) || - props.separateAfter?.(previous?.id) + return props.separate || + (previous instanceof BoxRenderable && (previous.height > 1 || alwaysSeparate.has(previous))) ? 1 : 0 }) @@ -2349,7 +2337,7 @@ function BlockTool(props: { const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined)) return ( alwaysSeparate.add(el)} border={["left"]} paddingTop={1} paddingBottom={1} @@ -2518,8 +2506,8 @@ function Read(props: ToolProps) { Read {pathFormatter.format(stringValue(props.input.filePath))} {input(props.input, ["filePath"])} - {(filepath, index) => ( - + {(filepath) => ( + ↳ Loaded {pathFormatter.format(filepath)} @@ -2639,7 +2627,7 @@ function Task(props: ToolProps) { return ( { titleView?: JSX.Element placeholder?: string footer?: JSX.Element + emptyView?: JSX.Element options: DialogSelectOption[] flat?: boolean ref?: (ref: DialogSelectRef) => void @@ -548,9 +549,11 @@ export function DialogSelect(props: DialogSelectProps) { 0} fallback={ - - No results found - + props.emptyView ?? ( + + No results found + + ) } > { test("refreshes integrations after integration updates", async () => { const events = createEventSource() - let requests = 0 + const requests = { integration: 0, model: 0, provider: 0 } const calls = createFetch((url) => { + if (url.pathname === "/api/model") { + requests.model++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) + } + if (url.pathname === "/api/provider") { + requests.provider++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) + } if (url.pathname !== "/api/integration") return - requests++ + requests.integration++ return json({ location: { directory, project: { id: "proj_test", directory } }, data: - requests === 1 + requests.integration === 1 ? [] : [ { @@ -156,15 +164,57 @@ test("refreshes integrations after integration updates", async () => { await mounted await wait(() => data.location.integration.list() !== undefined) expect(data.location.integration.list()).toEqual([]) + const before = { ...requests } emitEvent(events, { id: "evt_integration", type: "integration.updated", properties: {} }) await wait(() => data.location.integration.list()?.length === 1) + await wait(() => requests.model > before.model && requests.provider > before.provider) expect(data.location.integration.list()?.[0]).toMatchObject({ id: "openai", name: "OpenAI" }) } finally { app.renderer.destroy() } }) +test("refreshes effective catalog data after catalog updates", async () => { + const events = createEventSource() + const requests = { model: 0, provider: 0 } + const calls = createFetch((url) => { + if (url.pathname === "/api/model") { + requests.model++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) + } + if (url.pathname === "/api/provider") { + requests.provider++ + return json({ location: { directory, project: { id: "proj_test", directory } }, data: [] }) + } + }) + + const app = await testRender(() => ( + + + + {/* kilocode_change start - initialize Kilo's project filter before consuming catalog events */} + + + + + + {/* kilocode_change end */} + + + + )) + + try { + await wait(() => requests.model > 0 && requests.provider > 0) + const before = { ...requests } + emitEvent(events, { id: "evt_catalog", type: "catalog.updated", properties: {} }) + await wait(() => requests.model > before.model && requests.provider > before.provider) + } finally { + app.renderer.destroy() + } +}) + test("refreshes references after updates", async () => { const events = createEventSource() let requests = 0 diff --git a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx index 348a4d0440..6262066d3c 100644 --- a/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx +++ b/packages/tui/test/cli/tui/inline-tool-wrap-snapshot.test.tsx @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { createSignal, For, Show } from "solid-js" -import type { ScrollBoxRenderable } from "@opentui/core" +import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" import { testRender, type JSX } from "@opentui/solid" import { formatCompletedSubagentDetail, @@ -13,6 +13,7 @@ import { parseQuestionAnswers, parseQuestions, parseTodos, + alwaysSeparate, toolDisplay, } from "../../../src/routes/session" @@ -53,7 +54,14 @@ const tools: readonly ToolFixture[] = [ function ShellOutput() { return ( - + alwaysSeparate.add(el)} + marginTop={1} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + gap={1} + > # List files $ ls @@ -65,7 +73,7 @@ function ShellOutput() { function UserMessage() { return ( - + alwaysSeparate.add(el)}> Check whether the next tool remains separated. @@ -88,7 +96,6 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) failed={Boolean(item.error)} error={item.error} errorExpanded={props.errorExpanded} - separateAfter={(id) => id === "message-user"} > {item.label} @@ -99,61 +106,67 @@ function Fixture(props: { errorExpanded?: boolean; before?: "shell" | "user" }) ) } -function SubagentGroupFixture() { +function TaskRowsFixture() { return ( - + Grep "Task" (2 matches) - + Explore Task — Inspect active task spacing - + {"General Task — Confirm completed task spacing\n↳ 1 toolcall · 501ms"} - + Read src/cli/cmd/tui/routes/session/index.tsx ) } -function LoadedReadBeforeSubagentFixture() { +function LoadedReadBeforeTaskFixture() { return ( - + Read src/cli/cmd/tui/routes/session/index.tsx - + ↳ Loaded src/cli/cmd/tui/routes/session/tools.tsx - + {"Explore Task — Inspect active task spacing\n↳ 1 toolcall · 501ms"} ) } -function AssistantSummaryBeforeSubagentFixture() { +function AssistantSummaryBeforeInlineFixture() { return ( - + alwaysSeparate.add(el)} paddingLeft={3}> ▣ Build · Little Frank · 53.1s - + {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} ) } -function AssistantErrorBeforeSubagentFixture() { +function AssistantErrorBeforeInlineFixture() { return ( - + alwaysSeparate.add(el)} + border={["left"]} + paddingTop={1} + paddingBottom={1} + paddingLeft={2} + > Managed inference requires an active Member plan - + {"Build Task — Review changes\n↳ 48 toolcalls · 1m 40s"} @@ -170,7 +183,7 @@ function StickyScrollFixture(props: { separated: boolean; scroll: (scroll: Scrol Second row - + alwaysSeparate.add(el)}> Assistant text @@ -200,6 +213,7 @@ function FailedCompleteToolFixture() { async function renderFrame(component: () => JSX.Element, options: { width: number; height: number }) { testSetup = await testRender(component, options) await testSetup.renderOnce() + await testSetup.renderOnce() return testSetup .captureCharFrame() @@ -299,22 +313,20 @@ describe("TUI inline tool wrapping", () => { expect(await renderFrame(() => , { width: 72, height: 14 })).toMatchSnapshot() }) - test("separates a contiguous subagent group from inline tools", async () => { - expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() + test("separates after a multi-line task row", async () => { + expect(await renderFrame(() => , { width: 72, height: 10 })).toMatchSnapshot() }) - test("separates a subagent group after an expanded read", async () => { - expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() + test("separates a task row from a preceding inline detail", async () => { + expect(await renderFrame(() => , { width: 72, height: 8 })).toMatchSnapshot() }) - test("separates a subagent from the previous assistant summary", async () => { - expect( - await renderFrame(() => , { width: 72, height: 5 }), - ).toMatchSnapshot() + test("separates an inline row from the previous assistant summary", async () => { + expect(await renderFrame(() => , { width: 72, height: 5 })).toMatchSnapshot() }) - test("separates a subagent from the previous assistant error", async () => { - expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() + test("separates an inline row from the previous assistant error", async () => { + expect(await renderFrame(() => , { width: 72, height: 7 })).toMatchSnapshot() }) test("updates sticky-bottom geometry when a text separator mounts and unmounts", async () => { diff --git a/packages/tui/test/fixture/tui-sdk.ts b/packages/tui/test/fixture/tui-sdk.ts index d0c3c2c009..3b738b9915 100644 --- a/packages/tui/test/fixture/tui-sdk.ts +++ b/packages/tui/test/fixture/tui-sdk.ts @@ -68,6 +68,7 @@ export function createFetch(override?: FetchHandler) { // kilocode_change end if (url.pathname === "/config/providers") return json({ providers: {}, default: {} }) if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 }) + if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false }) if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory }) if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } }) if ( diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a6b262cb8..d5b27305ac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,10 +30,10 @@ "scripts": { "typecheck": "tsgo --noEmit", "test": "bun test src --only-failures", - "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts", - "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts" + "generate:v2-oc2": "bun run script/build-oc2-v2-overrides.ts", + "test:ci": "mkdir -p .artifacts/unit && bun test src --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml" }, "devDependencies": { "@tailwindcss/vite": "catalog:", @@ -81,7 +81,8 @@ "@solid-primitives/bounds": "0.1.3", "luxon": "catalog:", "marked-katex-extension": "5.1.6", - "marked-shiki": "catalog:" + "marked-shiki": "catalog:", + "@shikijs/stream": "catalog:" }, "peerDependencies": {} } diff --git a/packages/ui/src/components/file.css b/packages/ui/src/components/file.css index a9150e1450..a17732f614 100644 --- a/packages/ui/src/components/file.css +++ b/packages/ui/src/components/file.css @@ -2,6 +2,10 @@ content-visibility: auto; } +[data-timeline-row] [data-component="file"] { + content-visibility: visible; +} + [data-component="file"][data-mode="text"] { overflow: hidden; } diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx index bfcc05b40a..8c8096375a 100644 --- a/packages/ui/src/components/file.tsx +++ b/packages/ui/src/components/file.tsx @@ -52,7 +52,7 @@ const VIRTUALIZE_BYTES = 500_000 const codeMetrics = { ...DEFAULT_VIRTUAL_FILE_METRICS, lineHeight: 24, - fileGap: 0, + spacing: 0, } satisfies Partial type SharedProps = { diff --git a/packages/ui/src/components/markdown-code-state.test.ts b/packages/ui/src/components/markdown-code-state.test.ts new file mode 100644 index 0000000000..1be76d6f16 --- /dev/null +++ b/packages/ui/src/components/markdown-code-state.test.ts @@ -0,0 +1,32 @@ +import { expect, test } from "bun:test" +import { shouldResetCodeTokens } from "./markdown-code-state" + +const previous = { + language: "ts", + generation: 1, + stableCount: 3, + unstable: [], + raw: "```ts\nconst x = 1\n```", +} + +test("resets tokens for a non-prefix replacement with the same generation and token count", () => { + expect( + shouldResetCodeTokens(previous, { + language: "ts", + generation: 1, + stableCount: 3, + raw: "```ts\nlet y = 2\n```", + }), + ).toBe(true) +}) + +test("retains tokens for an append-only streaming update", () => { + expect( + shouldResetCodeTokens(previous, { + language: "ts", + generation: 1, + stableCount: 4, + raw: `${previous.raw}\nmore`, + }), + ).toBe(false) +}) diff --git a/packages/ui/src/components/markdown-code-state.ts b/packages/ui/src/components/markdown-code-state.ts new file mode 100644 index 0000000000..502f3ecd3c --- /dev/null +++ b/packages/ui/src/components/markdown-code-state.ts @@ -0,0 +1,22 @@ +import type { MarkdownToken } from "./markdown-worker-protocol" + +export type RenderedCodeState = { + language: string + generation: number + stableCount: number + unstable: MarkdownToken[] + raw: string +} + +export function shouldResetCodeTokens( + previous: RenderedCodeState | undefined, + next: { language: string; generation: number; stableCount: number; raw: string }, +) { + return ( + !previous || + previous.language !== next.language || + previous.generation !== next.generation || + next.stableCount < previous.stableCount || + !next.raw.startsWith(previous.raw) + ) +} diff --git a/packages/ui/src/components/markdown-shiki.worker.ts b/packages/ui/src/components/markdown-shiki.worker.ts new file mode 100644 index 0000000000..625ae98d4d --- /dev/null +++ b/packages/ui/src/components/markdown-shiki.worker.ts @@ -0,0 +1,106 @@ +/// + +import { ShikiStreamTokenizer } from "@shikijs/stream" +import { + bundledLanguages, + createHighlighter, + getTokenStyleObject, + stringifyTokenStyle, + type BundledLanguage, + type ThemedToken, +} from "shiki" +import type { MarkdownToken, MarkdownWorkerRequest, MarkdownWorkerResponse } from "./markdown-worker-protocol" +import { createLatestWorkerQueue } from "./markdown-worker-queue" + +type Stream = { + language: string + source: string + tokenizer: ShikiStreamTokenizer +} + +const streams = new Map() +let highlighter: ReturnType | undefined +let theme = "Kilo" // kilocode_change - use the Kilo theme supplied during worker initialization +const queue = createLatestWorkerQueue>({ + run: highlight, + supersede: (request) => post({ type: "superseded", id: request.id, key: request.key }), + dispose: (key) => void streams.delete(key), +}) + +self.onmessage = (event: MessageEvent) => { + if (event.data.type === "init") { + theme = event.data.theme.name // kilocode_change + highlighter ??= createHighlighter({ themes: [event.data.theme], langs: [] }) + return + } + if (event.data.type === "dispose") { + queue.dispose(event.data.key) + return + } + + queue.highlight(event.data) +} + +async function highlight(request: Extract) { + try { + const instance = await highlighter + if (!instance) throw new Error("Shiki worker is not initialized") + const language = request.language in bundledLanguages ? request.language : "text" + if (!instance.getLoadedLanguages().includes(language)) + await instance.loadLanguage(bundledLanguages[language as BundledLanguage]) + + if (request.complete) { + const result = instance.codeToTokens(request.text, { lang: language as BundledLanguage, theme }) + streams.delete(request.key) + post({ + type: "highlight", + id: request.id, + key: request.key, + reset: true, + stable: result.tokens + .flatMap((line, index) => + index === result.tokens.length - 1 ? line : [...line, { content: "\n", offset: 0 }], + ) + .map(token), + unstable: [], + }) + return + } + + const previous = streams.get(request.key) + const reset = !previous || previous.language !== language || !request.text.startsWith(previous.source) + const stream = reset + ? { + language, + source: "", + tokenizer: new ShikiStreamTokenizer({ highlighter: instance, lang: language, theme }), + } + : previous + const result = await stream.tokenizer.enqueue(request.text.slice(stream.source.length)) + stream.source = request.text + streams.set(request.key, stream) + post({ + type: "highlight", + id: request.id, + key: request.key, + reset, + stable: result.stable.filter((token) => token.content.length > 0).map(token), + unstable: result.unstable.filter((token) => token.content.length > 0).map(token), + }) + } catch (error) { + post({ + type: "error", + id: request.id, + key: request.key, + message: error instanceof Error ? error.message : String(error), + }) + } +} + +function post(response: MarkdownWorkerResponse) { + self.postMessage(response) +} + +function token(value: ThemedToken): MarkdownToken { + return [value.content, stringifyTokenStyle(value.htmlStyle ?? getTokenStyleObject(value))] +} diff --git a/packages/ui/src/components/markdown-stream.test.ts b/packages/ui/src/components/markdown-stream.test.ts index 1ee63fc62e..e792a33839 100644 --- a/packages/ui/src/components/markdown-stream.test.ts +++ b/packages/ui/src/components/markdown-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { stream } from "./markdown-stream" +import { canReusePendingBlock, project, stream } from "./markdown-stream" describe("markdown stream", () => { test("heals incomplete emphasis while streaming", () => { @@ -15,8 +15,58 @@ describe("markdown stream", () => { test("splits an unfinished trailing code fence from stable content", () => { expect(stream("before\n\n```ts\nconst x = 1", true)).toEqual([ - { raw: "before\n\n", src: "before\n\n", mode: "live" }, - { raw: "```ts\nconst x = 1", src: "```ts\nconst x = 1", mode: "live" }, + { raw: "before\n\n", src: "before\n\n", mode: "full" }, + { raw: "```ts\nconst x = 1", src: "const x = 1", mode: "code", language: "ts" }, + ]) + }) + + test("fully parses a code fence once it closes", () => { + const text = "before\n\n```ts\nconst x = 1\n```" + expect(stream(text, true)).toEqual([ + { raw: "before\n\n", src: "before\n\n", mode: "full" }, + { raw: "```ts\nconst x = 1\n```", src: "const x = 1", mode: "code", language: "ts", complete: true }, + ]) + }) + + test("keeps a completed code fence in worker-rendered code mode when prose follows", () => { + expect(stream("```ts\nconst x = 1\n```\n\nafter", true)).toEqual([ + { raw: "```ts\nconst x = 1\n```\n\n", src: "const x = 1", mode: "code", language: "ts", complete: true }, + { raw: "after", src: "after", mode: "live" }, + ]) + }) + + test("freezes completed top-level blocks and only keeps the tail live", () => { + expect(stream("# Plan\n\nFinished paragraph.\n\n- live item", true)).toEqual([ + { raw: "# Plan\n\n", src: "# Plan\n\n", mode: "full" }, + { raw: "Finished paragraph.\n\n", src: "Finished paragraph.\n\n", mode: "full" }, + { raw: "- live item", src: "- live item", mode: "live" }, + ]) + }) + + test("keeps a growing table together until a later block freezes it", () => { + expect(stream("| a | b |\n|---|---|\n| 1 | 2 |", true)).toEqual([ + { raw: "| a | b |\n|---|---|\n| 1 | 2 |", src: "| a | b |\n|---|---|\n| 1 | 2 |", mode: "live" }, + ]) + }) + + test("reprojects non-prefix replacements from current content", () => { + expect(stream("# Replacement\n\nNew body", true)).toEqual([ + { raw: "# Replacement\n\n", src: "# Replacement\n\n", mode: "full" }, + { raw: "New body", src: "New body", mode: "live" }, + ]) + }) + + test("reprojects truncation without retaining removed blocks", () => { + expect(stream("Only the restored prefix", true)).toEqual([ + { raw: "Only the restored prefix", src: "Only the restored prefix", mode: "live" }, + ]) + }) + + test("shifts later blocks when an earlier block is inserted", () => { + expect(stream("# Inserted\n\nFirst body\n\nSecond body", true)).toEqual([ + { raw: "# Inserted\n\n", src: "# Inserted\n\n", mode: "full" }, + { raw: "First body\n\n", src: "First body\n\n", mode: "full" }, + { raw: "Second body", src: "Second body", mode: "live" }, ]) }) @@ -29,4 +79,116 @@ describe("markdown stream", () => { }, ]) }) + + test("keeps compact and indented reference definitions with their uses", () => { + expect(stream("[docs]\n\n [docs]:/guide", true)).toEqual([ + { + raw: "[docs]\n\n [docs]:/guide", + src: "[docs]\n\n [docs]:/guide", + mode: "live", + }, + ]) + }) + + test("keeps multiline reference definitions with their uses", () => { + expect(stream("[docs][id]\n\n[id]:\n /guide", true)).toEqual([ + { + raw: "[docs][id]\n\n[id]:\n /guide", + src: "[docs][id]\n\n[id]:\n /guide", + mode: "live", + }, + ]) + }) + + test("uses only the language portion of fence metadata", () => { + expect(stream("```ts title=example\nconst x = 1", true)).toEqual([ + { + raw: "```ts title=example\nconst x = 1", + src: "const x = 1", + mode: "code", + language: "ts", + }, + ]) + }) + + test("preserves trailing newlines in open code fences", () => { + expect(stream("```ts\nconst x = 1\n", true)).toEqual([ + { + raw: "```ts\nconst x = 1\n", + src: "const x = 1\n", + mode: "code", + language: "ts", + }, + ]) + }) + + test("only reuses pending blocks with compatible identity and content", () => { + expect( + canReusePendingBlock({ mode: "full", raw: "First\n\n" }, { mode: "full", raw: "# Inserted\n\n", src: "" }), + ).toBe(false) + expect( + canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "code", raw: "```ts\none two", src: "" }), + ).toBe(true) + expect(canReusePendingBlock({ mode: "code", raw: "```ts\none" }, { mode: "live", raw: "one", src: "" })).toBe(false) + }) + + test("appends plain code deltas without reprojecting frozen blocks", () => { + const previous = project(undefined, "# Plan\n\n```ts\nconst one = 1\n", true) + const next = project(previous, `${previous.text}const two = 2\n`, true) + + expect(next.blocks[0]).toBe(previous.blocks[0]) + expect(next.blocks.at(-1)).toEqual({ + raw: "```ts\nconst one = 1\nconst two = 2\n", + src: "const one = 1\nconst two = 2\n", + mode: "code", + language: "ts", + }) + }) + + test("does not add a blank line before the first streamed code", () => { + const previous = project(undefined, "```ts\n", true) + const next = project(previous, `${previous.text}const x = 1`, true) + + expect(next.blocks.at(-1)).toEqual({ + raw: "```ts\nconst x = 1", + src: "const x = 1", + mode: "code", + language: "ts", + }) + }) + + test("closes code fences split across provider deltas", () => { + const open = project(undefined, "```ts\nconst x = 1\n", true) + const one = project(open, `${open.text}\``, true) + const two = project(one, `${one.text}\``, true) + const closed = project(two, `${two.text}\``, true) + const prose = project(closed, `${closed.text}\nafter`, true) + + expect(closed.blocks.at(-1)).toEqual({ + raw: "```ts\nconst x = 1\n```", + src: "const x = 1", + mode: "code", + language: "ts", + complete: true, + }) + expect(prose.blocks).toEqual([ + { raw: "```ts\nconst x = 1\n```\n", src: "const x = 1", mode: "code", language: "ts", complete: true }, + { raw: "after", src: "after", mode: "live" }, + ]) + }) + + test("closes tilde fences split across provider deltas", () => { + const open = project(undefined, "~~~ts\nconst x = 1\n", true) + const one = project(open, `${open.text}~`, true) + const two = project(one, `${one.text}~`, true) + const closed = project(two, `${two.text}~`, true) + + expect(closed.blocks.at(-1)).toEqual({ + raw: "~~~ts\nconst x = 1\n~~~", + src: "const x = 1", + mode: "code", + language: "ts", + complete: true, + }) + }) }) diff --git a/packages/ui/src/components/markdown-stream.ts b/packages/ui/src/components/markdown-stream.ts index ae034275c7..3b1c252660 100644 --- a/packages/ui/src/components/markdown-stream.ts +++ b/packages/ui/src/components/markdown-stream.ts @@ -1,15 +1,31 @@ import { marked, type Tokens } from "marked" import remend from "remend" -import { stableBlocks } from "../kilocode/markdown-stable-blocks" // kilocode_change export type Block = { raw: string src: string - mode: "full" | "live" + mode: "full" | "live" | "code" + language?: string + complete?: boolean +} + +export type Projection = { + text: string + blocks: Block[] } function refs(text: string) { - return /^\[[^\]]+\]:\s+\S+/m.test(text) || /^\[\^[^\]]+\]:\s+/m.test(text) + if (!text.includes("]:")) return false + return /^[ \t]{0,3}\[[^\]]+\]:[ \t]*(?:\S+|\r?\n[ \t]+\S+)/m.test(text) +} + +function language(value: string | undefined) { + return value?.trim().split(/\s+/, 1)[0] || undefined +} + +function openCode(raw: string) { + const newline = raw.indexOf("\n") + return newline < 0 ? "" : raw.slice(newline + 1) } function open(raw: string) { @@ -23,31 +39,72 @@ function open(raw: string) { return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last) } +function closesFence(raw: string, suffix: string) { + const mark = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)?.[1] + if (!mark) return suffix.includes("```") || suffix.includes("~~~") + return `${raw.slice(-(mark.length - 1))}${suffix}`.includes(mark) +} + function heal(text: string) { return remend(text, { linkMode: "text-only" }) } -export function stream(text: string, live: boolean) { +export function stream(text: string, live: boolean): Block[] { if (!live) return [{ raw: text, src: text, mode: "full" }] satisfies Block[] - const src = heal(text) - if (refs(text)) return [{ raw: text, src, mode: "live" }] satisfies Block[] + if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const tokens = marked.lexer(text) - const candidate = tokens.findLast((token) => token.type !== "space") // kilocode_change - const blocks = candidate && !open(candidate.raw) ? stableBlocks(tokens, heal) : undefined // kilocode_change - if (blocks) return blocks // kilocode_change const tail = tokens.findLastIndex((token) => token.type !== "space") - if (tail < 0) return [{ raw: text, src, mode: "live" }] satisfies Block[] + if (tail < 0) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] const last = tokens[tail] - if (!last || last.type !== "code") return [{ raw: text, src, mode: "live" }] satisfies Block[] - const code = last as Tokens.Code - if (!open(code.raw)) return [{ raw: text, src, mode: "live" }] satisfies Block[] - const head = tokens - .slice(0, tail) + if (!last) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[] + + const result: Block[] = [] + for (let index = 0; index < tail; index++) { + const token = tokens[index] + if (!token || token.type === "space") continue + let raw = token.raw + while (tokens[index + 1]?.type === "space" && index + 1 < tail) raw += tokens[++index]!.raw + if (token.type === "code") { + const code = token as Tokens.Code + result.push({ raw, src: code.text, mode: "code", language: language(code.lang), complete: true }) + continue + } + result.push({ raw, src: raw, mode: "full" }) + } + + const raw = tokens + .slice(tail) .map((token) => token.raw) .join("") - if (!head) return [{ raw: code.raw, src: code.raw, mode: "live" }] satisfies Block[] - return [ - { raw: head, src: heal(head), mode: "live" }, - { raw: code.raw, src: code.raw, mode: "live" }, - ] satisfies Block[] + if (last.type !== "code") return [...result, { raw, src: heal(raw), mode: "live" }] + + const code = last as Tokens.Code + if (!open(code.raw)) + return [...result, { raw, src: code.text, mode: "code", language: language(code.lang), complete: true }] + return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }] +} + +export function canReusePendingBlock(current: Pick | undefined, next: Block) { + if (!current || current.mode !== next.mode) return false + if (next.mode === "code") return next.raw.startsWith(current.raw) + return current.raw === next.raw +} + +export function project(previous: Projection | undefined, text: string, live: boolean): Projection { + if (!live || !previous || !text.startsWith(previous.text)) return { text, blocks: stream(text, live) } + const tail = previous.blocks.at(-1) + const suffix = text.slice(previous.text.length) + if (!suffix || tail?.mode !== "code" || tail.complete || closesFence(tail.raw, suffix)) + return { text, blocks: stream(text, live) } + return { + text, + blocks: [ + ...previous.blocks.slice(0, -1), + { + ...tail, + raw: tail.raw + suffix, + src: tail.src + suffix, + }, + ], + } } diff --git a/packages/ui/src/components/markdown-worker-protocol.test.ts b/packages/ui/src/components/markdown-worker-protocol.test.ts new file mode 100644 index 0000000000..45a169c6a2 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.test.ts @@ -0,0 +1,81 @@ +import { expect, test } from "bun:test" +import { + applyMarkdownWorkerResponse, + markdownBlockKey, + shouldReleaseMarkdownWorkerState, +} from "./markdown-worker-protocol" + +const token = (content: string): [string, string] => [content, ""] +const response = (id: number, reset: boolean, stable: [string, string][], unstable: [string, string][]) => ({ + type: "highlight" as const, + id, + key: "code", + reset, + stable, + unstable, +}) + +test("accumulates stable worker tokens and replaces the unstable tail", () => { + const first = applyMarkdownWorkerResponse(undefined, { + type: "highlight", + id: 1, + key: "code", + reset: true, + stable: [token("one\n")], + unstable: [token("tw")], + }) + const second = applyMarkdownWorkerResponse(first, { + type: "highlight", + id: 2, + key: "code", + reset: false, + stable: [token("two\n")], + unstable: [token("three")], + }) + + expect(second.stable.map((item) => item[0])).toEqual(["one\n", "two\n"]) + expect(second.unstable.map((item) => item[0])).toEqual(["three"]) +}) + +test("increments generation only when the worker resets token identity", () => { + const first = applyMarkdownWorkerResponse(undefined, response(1, true, [["const", ""]], [])) + const append = applyMarkdownWorkerResponse(first, response(2, false, [[" x", ""]], [])) + const replacement = applyMarkdownWorkerResponse(append, response(3, true, [["let y", ""]], [])) + expect([first.generation, append.generation, replacement.generation]).toEqual([1, 1, 2]) +}) + +test("ignores stale worker responses and resets replacement streams", () => { + const current = { id: 2, generation: 1, stable: [token("current")], unstable: [] } + expect( + applyMarkdownWorkerResponse(current, { + type: "highlight", + id: 1, + key: "code", + reset: false, + stable: [token("stale")], + unstable: [], + }), + ).toBe(current) + + expect( + applyMarkdownWorkerResponse(current, { + type: "highlight", + id: 3, + key: "code", + reset: true, + stable: [token("replacement")], + unstable: [], + }).stable.map((item) => item[0]), + ).toEqual(["replacement"]) +}) + +test("releases only the latest completed worker state", () => { + expect(shouldReleaseMarkdownWorkerState(true, 4, 4)).toBe(true) + expect(shouldReleaseMarkdownWorkerState(true, 5, 4)).toBe(false) + expect(shouldReleaseMarkdownWorkerState(false, 4, 4)).toBe(false) +}) + +test("prefixes pending and dispatched block keys with the component owner", () => { + expect(markdownBlockKey("owner", "message", 2, "code")).toBe("owner:message:2:code") + expect(markdownBlockKey("owner", undefined, 2, "code")).toBe("owner:block:2") +}) diff --git a/packages/ui/src/components/markdown-worker-protocol.ts b/packages/ui/src/components/markdown-worker-protocol.ts new file mode 100644 index 0000000000..e0b59e38df --- /dev/null +++ b/packages/ui/src/components/markdown-worker-protocol.ts @@ -0,0 +1,48 @@ +import type { ThemeRegistrationResolved } from "shiki" + +export type MarkdownToken = [content: string, style: string] + +export type MarkdownWorkerRequest = + | { type: "init"; theme: ThemeRegistrationResolved } + | { type: "highlight"; id: number; key: string; text: string; language: string; complete?: boolean } + | { type: "dispose"; key: string } + +export type MarkdownWorkerResponse = + | { + type: "highlight" + id: number + key: string + reset: boolean + stable: MarkdownToken[] + unstable: MarkdownToken[] + } + | { type: "error"; id: number; key: string; message: string } + | { type: "superseded"; id: number; key: string } + +export type MarkdownWorkerState = { + id: number + generation: number + stable: MarkdownToken[] + unstable: MarkdownToken[] +} + +export function shouldReleaseMarkdownWorkerState(complete: boolean, latestID: number | undefined, responseID: number) { + return complete && latestID === responseID +} + +export function markdownBlockKey(owner: string, cacheKey: string | undefined, index: number, mode: string) { + return `${owner}:${cacheKey ? `${cacheKey}:${index}:${mode}` : `block:${index}`}` +} + +export function applyMarkdownWorkerResponse( + state: MarkdownWorkerState | undefined, + response: Extract, +) { + if (state && response.id <= state.id) return state + return { + id: response.id, + generation: (state?.generation ?? 0) + (response.reset ? 1 : 0), + stable: response.reset ? response.stable : [...(state?.stable ?? []), ...response.stable], + unstable: response.unstable, + } +} diff --git a/packages/ui/src/components/markdown-worker-queue.test.ts b/packages/ui/src/components/markdown-worker-queue.test.ts new file mode 100644 index 0000000000..4283bc958f --- /dev/null +++ b/packages/ui/src/components/markdown-worker-queue.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { createLatestWorkerQueue } from "./markdown-worker-queue" + +test("keeps only the latest queued request for each key", async () => { + const processed: number[] = [] + const superseded: number[] = [] + let release = () => {} + const blocked = new Promise((resolve) => { + release = resolve + }) + const queue = createLatestWorkerQueue<{ id: number; key: string }>({ + run: async (request) => { + processed.push(request.id) + if (request.id === 1) await blocked + }, + supersede: (request) => superseded.push(request.id), + dispose: () => {}, + }) + + queue.highlight({ id: 1, key: "code" }) + await Promise.resolve() + queue.highlight({ id: 2, key: "code" }) + queue.highlight({ id: 3, key: "code" }) + queue.highlight({ id: 4, key: "code" }) + + expect(queue.pending()).toBe(1) + expect(superseded).toEqual([2, 3]) + release() + await queue.idle() + expect(processed).toEqual([1, 4]) +}) + +test("serializes disposal before a later request for the same key", async () => { + const events: string[] = [] + const queue = createLatestWorkerQueue<{ id: number; key: string }>({ + run: async (request) => { + events.push(`highlight:${request.id}`) + }, + supersede: (request) => events.push(`supersede:${request.id}`), + dispose: (key) => events.push(`dispose:${key}`), + }) + + queue.highlight({ id: 1, key: "code" }) + queue.dispose("code") + queue.highlight({ id: 2, key: "code" }) + await queue.idle() + + expect(events).toEqual(["supersede:1", "dispose:code", "highlight:2"]) +}) diff --git a/packages/ui/src/components/markdown-worker-queue.ts b/packages/ui/src/components/markdown-worker-queue.ts new file mode 100644 index 0000000000..f771157634 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-queue.ts @@ -0,0 +1,64 @@ +export function createLatestWorkerQueue(input: { + run: (request: T) => Promise + supersede: (request: T) => void + dispose: (key: string) => void +}) { + type Slot = { type: "highlight"; key: string; request?: T } + const jobs: Array = [] + const slots = new Map() + let running: Promise | undefined + let cursor = 0 + + const schedule = () => { + if (running) return + running = Promise.resolve() + .then(async () => { + while (cursor < jobs.length) { + const job = jobs[cursor++]! + if (job.type === "dispose") { + input.dispose(job.key) + continue + } + if (slots.get(job.key) === job) slots.delete(job.key) + const request = job.request + job.request = undefined + if (request) await input.run(request) + } + }) + .finally(() => { + jobs.splice(0, cursor) + cursor = 0 + running = undefined + if (jobs.length > 0) schedule() + }) + } + + return { + highlight(request: T) { + const slot = slots.get(request.key) + if (slot) { + if (slot.request) input.supersede(slot.request) + slot.request = request + return + } + const next: Slot = { type: "highlight", key: request.key, request } + slots.set(request.key, next) + jobs.push(next) + schedule() + }, + dispose(key: string) { + const slot = slots.get(key) + if (slot?.request) input.supersede(slot.request) + if (slot) { + slot.request = undefined + slots.delete(key) + } + jobs.push({ type: "dispose", key }) + schedule() + }, + pending: () => slots.size, + async idle() { + while (running) await running + }, + } +} diff --git a/packages/ui/src/components/markdown-worker-transport.test.ts b/packages/ui/src/components/markdown-worker-transport.test.ts new file mode 100644 index 0000000000..24cf842a4a --- /dev/null +++ b/packages/ui/src/components/markdown-worker-transport.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test" +import { createWorkerTransport } from "./markdown-worker-transport" + +test("posts one request and retains only the latest queued snapshot per key", () => { + const posted: number[] = [] + const superseded: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: (request) => posted.push(request.id), + supersede: (request) => superseded.push(request.id), + }) + + transport.send({ id: 1, key: "code" }) + transport.send({ id: 2, key: "code" }) + transport.send({ id: 3, key: "code" }) + + expect(posted).toEqual([1]) + expect(superseded).toEqual([2]) + expect(transport.queued()).toBe(1) + transport.complete("code", 1) + expect(posted).toEqual([1, 3]) + expect(transport.queued()).toBe(0) +}) + +test("ignores a disposed request response after the key is reused", () => { + const posted: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: (request) => posted.push(request.id), + supersede: () => {}, + }) + + transport.send({ id: 1, key: "code" }) + transport.dispose("code") + transport.send({ id: 2, key: "code" }) + transport.send({ id: 3, key: "code" }) + transport.complete("code", 1) + + expect(posted).toEqual([1, 2]) + expect(transport.queued()).toBe(1) + transport.complete("code", 2) + expect(posted).toEqual([1, 2, 3]) +}) + +test("drops queued snapshots when a key is disposed", () => { + const superseded: number[] = [] + const transport = createWorkerTransport<{ id: number; key: string }>({ + post: () => {}, + supersede: (request) => superseded.push(request.id), + }) + + transport.send({ id: 1, key: "code" }) + transport.send({ id: 2, key: "code" }) + transport.dispose("code") + + expect(superseded).toEqual([2]) + expect(transport.queued()).toBe(0) +}) diff --git a/packages/ui/src/components/markdown-worker-transport.ts b/packages/ui/src/components/markdown-worker-transport.ts new file mode 100644 index 0000000000..acdac7b1c9 --- /dev/null +++ b/packages/ui/src/components/markdown-worker-transport.ts @@ -0,0 +1,41 @@ +export function createWorkerTransport(input: { + post: (request: T) => void + supersede: (request: T) => void +}) { + const active = new Map() + const queued = new Map() + + return { + send(request: T) { + if (!active.has(request.key)) { + active.set(request.key, request) + input.post(request) + return + } + const previous = queued.get(request.key) + if (previous) input.supersede(previous) + queued.set(request.key, request) + }, + complete(key: string, id: number) { + if (active.get(key)?.id !== id) return + active.delete(key) + const next = queued.get(key) + if (!next) return + queued.delete(key) + active.set(key, next) + input.post(next) + }, + dispose(key: string) { + active.delete(key) + const request = queued.get(key) + if (request) input.supersede(request) + queued.delete(key) + }, + reset() { + queued.forEach(input.supersede) + queued.clear() + active.clear() + }, + queued: () => queued.size, + } +} diff --git a/packages/ui/src/components/markdown-worker.ts b/packages/ui/src/components/markdown-worker.ts new file mode 100644 index 0000000000..1293fe71e6 --- /dev/null +++ b/packages/ui/src/components/markdown-worker.ts @@ -0,0 +1,122 @@ +import MarkdownShikiWorkerUrl from "./markdown-shiki.worker.ts?worker&url" +import { KiloTheme } from "../context/marked" // kilocode_change +import { + applyMarkdownWorkerResponse, + shouldReleaseMarkdownWorkerState, + type MarkdownWorkerRequest, + type MarkdownWorkerResponse, + type MarkdownWorkerState, +} from "./markdown-worker-protocol" +import { createWorkerTransport } from "./markdown-worker-transport" + +type Pending = { + key: string + complete: boolean + resolve: (state: MarkdownWorkerState) => void + reject: (error: Error) => void +} + +let worker: Worker | undefined +let disabled: Error | undefined +let nextID = 0 +const pending = new Map() +const states = new Map() +const keys = new Set() +const latest = new Map() +const transport = createWorkerTransport>({ + post: (request) => worker!.postMessage(request), + supersede: (request) => { + const result = pending.get(request.id) + if (!result) return + pending.delete(request.id) + result.reject(new MarkdownWorkerSupersededError()) + }, +}) + +export function highlightStreamingCode(key: string, text: string, language: string, complete = false) { + const instance = getWorker() + const id = ++nextID + latest.set(key, id) + keys.delete(key) + keys.add(key) + if (keys.size > 200) disposeStreamingCode(keys.values().next().value!) + return new Promise((resolve, reject) => { + pending.set(id, { key, complete, resolve, reject }) + transport.send({ type: "highlight", id, key, text, language, complete }) + }) +} + +export function disposeStreamingCode(key: string) { + keys.delete(key) + latest.delete(key) + states.delete(key) + transport.dispose(key) + pending.forEach((request, id) => { + if (request.key !== key) return + pending.delete(id) + request.reject(new MarkdownWorkerDisposedError()) + }) + worker?.postMessage({ type: "dispose", key } satisfies MarkdownWorkerRequest) +} + +export class MarkdownWorkerDisposedError extends Error {} +export class MarkdownWorkerSupersededError extends Error {} +export class MarkdownWorkerUnavailableError extends Error {} + +function getWorker() { + if (worker) return worker + if (disabled) throw new MarkdownWorkerUnavailableError(disabled.message) + try { + worker = new Worker(MarkdownShikiWorkerUrl, { type: "module" }) + } catch (error) { + disabled = error instanceof Error ? error : new Error(String(error)) + throw new MarkdownWorkerUnavailableError(disabled.message) + } + worker.onmessage = (event: MessageEvent) => { + const result = pending.get(event.data.id) + if (!result) { + transport.complete(event.data.key, event.data.id) + return + } + pending.delete(event.data.id) + if (!keys.has(event.data.key)) { + result.reject(new MarkdownWorkerDisposedError()) + transport.complete(event.data.key, event.data.id) + return + } + if (event.data.type === "superseded") { + result.reject(new MarkdownWorkerSupersededError()) + transport.complete(event.data.key, event.data.id) + return + } + if (event.data.type === "error") { + result.reject(new Error(event.data.message)) + transport.complete(event.data.key, event.data.id) + return + } + const state = applyMarkdownWorkerResponse(states.get(event.data.key), event.data) + if (shouldReleaseMarkdownWorkerState(result.complete, latest.get(event.data.key), event.data.id)) { + states.delete(event.data.key) + keys.delete(event.data.key) + latest.delete(event.data.key) + } else states.set(event.data.key, state) + result.resolve(state) + transport.complete(event.data.key, event.data.id) + } + const fail = (message: string) => { + const error = new Error(message) + disabled = error + transport.reset() + pending.forEach((request) => request.reject(error)) + pending.clear() + states.clear() + keys.clear() + latest.clear() + worker?.terminate() + worker = undefined + } + worker.onerror = (event) => fail(event.message || "Markdown highlighting worker failed") + worker.onmessageerror = () => fail("Markdown worker response failed") + worker.postMessage({ type: "init", theme: KiloTheme } satisfies MarkdownWorkerRequest) // kilocode_change + return worker +} diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index d246cb2302..ad25fbf272 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -15,6 +15,12 @@ > *:last-child { margin-bottom: 0; } + > [data-markdown-block]:first-child > *:first-child { + margin-top: 0; + } + > [data-markdown-block]:last-child > *:last-child { + margin-bottom: 0; + } /* Headings: Same size, distinguished by color and spacing */ h1, @@ -121,6 +127,8 @@ } .shiki { + background: var(--color-background-stronger); + color: var(--text-base); font-size: 13px; padding: 12px; border-radius: 6px; diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index a46b5fbd1e..8cef102376 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -4,23 +4,62 @@ import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" -import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" +import { + ComponentProps, + createEffect, + createMemo, + createResource, + createSignal, + createUniqueId, + onCleanup, + splitProps, +} from "solid-js" import { isServer } from "solid-js/web" -import { stream } from "./markdown-stream" -import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change -import { hasMermaid, preserveMermaid, renderMermaid, type MermaidLabels } from "../kilocode/markdown-mermaid" // kilocode_change -import { preserveStreamingHighlight } from "../kilocode/markdown-stream-highlight" // kilocode_change -import { createIncrementalMarkdown, type MarkdownBlock } from "../kilocode/markdown-incremental-dom" // kilocode_change +import { bundledLanguages } from "shiki" +import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream" +import { + disposeStreamingCode, + highlightStreamingCode, + MarkdownWorkerDisposedError, + MarkdownWorkerSupersededError, + MarkdownWorkerUnavailableError, +} from "./markdown-worker" +import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol" +import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" +// kilocode_change start: Mermaid rendering and morphdom guards for highlighted blocks +import { hasMermaid, preserveMermaid, renderMermaid, type MermaidLabels } from "../kilocode/markdown-mermaid" +import { preserveStreamingHighlight } from "../kilocode/markdown-stream-highlight" +// kilocode_change end type Entry = { + raw: string hash: string html: string } -type Rendered = { content: string; blocks: MarkdownBlock[] } // kilocode_change +type RenderedBlock = + | (Entry & { key: string; mode: Exclude }) + | { + key: string + mode: "code" + raw: string + src: string // kilocode_change - Mermaid consumes delimiter-free source while raw preserves stream identity + hash: string + language: string + complete: boolean + generation: number + stable: MarkdownToken[] + unstable: MarkdownToken[] + } + +type RenderResult = { + text: string + blocks: RenderedBlock[] +} const max = 200 const cache = new Map() +const renderedCodeTokens = new WeakMap() if (typeof window !== "undefined" && DOMPurify.isSupported) { DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { @@ -67,6 +106,22 @@ function fallback(markdown: string) { return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") } +async function code(text: string, language: string | undefined, key: string, complete = false) { + const name = language && language in bundledLanguages ? language : "text" + try { + const result = await highlightStreamingCode(key, text, name, complete) + return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable } + } catch (error) { + if ( + !(error instanceof MarkdownWorkerDisposedError) && + !(error instanceof MarkdownWorkerSupersededError) && + !(error instanceof MarkdownWorkerUnavailableError) + ) + console.error("Markdown highlighting worker failed", error) + return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] } + } +} + type CopyLabels = { copy: string copied: string @@ -245,6 +300,33 @@ function touch(key: string, value: Entry) { cache.delete(first) } +function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult { + if (!text) return { text, blocks: [] } + const base = key ?? checksum(text) + if (base) { + const blocks = projection.blocks.flatMap((block, index) => { + if (block.mode === "code") return [] + const cacheKey = `${base}:${index}:${block.mode}` + const cached = cache.get(cacheKey) + if (cached?.raw !== block.raw) return [] + return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] + }) + if (blocks.length === projection.blocks.length) return { text, blocks } + } + return { + text, + blocks: [ + { + key: "initial", + mode: "full", + raw: text, + hash: checksum(text) ?? "", + html: fallback(text), + }, + ], + } +} + export function Markdown( props: ComponentProps<"div"> & { text: string @@ -258,49 +340,117 @@ export function Markdown( const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() + const owner = createUniqueId() + const activeCodeKeys = new Set() + const completedCode = new Map>() + const projection = createMemo((previous: Projection | undefined) => + project(previous, local.text, local.streaming ?? false), + ) const [html] = createResource( - () => ({ - text: local.text, - key: local.cacheKey, - streaming: local.streaming ?? false, - }), - // kilocode_change start - async (src): Promise => { - // kilocode_change end - if (isServer) return { content: fallback(src.text), blocks: [] } // kilocode_change - if (!src.text) return { content: "", blocks: [] } // kilocode_change + () => { + return { + text: local.text, + key: local.cacheKey, + projection: projection(), + } + }, + async (src) => { + if (isServer) + return { + text: src.text, + blocks: [ + { + key: "server", + mode: "full" as const, + raw: src.text, + hash: checksum(src.text) ?? "", + html: fallback(src.text), + }, + ], + } satisfies RenderResult + if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult const base = src.key ?? checksum(src.text) return Promise.all( - stream(src.text, src.streaming).map(async (block, index) => { - const hash = checksum(block.raw) ?? "" // kilocode_change - const key = base ? `${base}:${index}:${block.mode}` : hash + src.projection.blocks.map(async (block, index) => { + const key = base ? `${base}:${index}:${block.mode}` : undefined + const blockKey = markdownBlockKey(owner, src.key, index, block.mode) - if (key && hash) { + if (block.mode === "code") { + // kilocode_change start: mermaid blocks are rendered as diagrams by + // kickMermaid, not Shiki-highlighted by the worker. Return plain + // text tokens so updateCodeBlock can emit a
+            // source block for renderMermaid to transform.
+            if (block.language === "mermaid") {
+              return {
+                key: blockKey,
+                mode: block.mode,
+                raw: block.raw,
+                src: block.src, // kilocode_change
+                hash: String(block.raw.length),
+                complete: !!block.complete,
+                language: "mermaid",
+                generation: 0,
+                stable: [],
+                unstable: [[block.src, ""] as MarkdownToken],
+              }
+            }
+            // kilocode_change end
+            const cached = completedCode.get(blockKey)
+            if (block.complete && cached?.raw === block.raw) return cached
+            const result = await code(block.src, block.language, blockKey, block.complete)
+            const rendered = {
+              key: blockKey,
+              mode: block.mode,
+              raw: block.raw,
+              src: block.src, // kilocode_change
+              hash: String(block.raw.length),
+              complete: !!block.complete,
+              ...result,
+            }
+            if (block.complete) completedCode.set(blockKey, rendered)
+            return rendered
+          }
+
+          if (key) {
             const cached = cache.get(key)
-            if (cached && cached.hash === hash) {
+            if (cached?.raw === block.raw) {
               touch(key, cached)
-              return { key: `${base}:${index}`, hash, html: cached.html, mode: block.mode } // kilocode_change
+              return { key: blockKey, mode: block.mode, ...cached }
             }
           }
 
-          const next = await Promise.resolve(marked.parse(block.src))
-          const safe = sanitize(next)
-          if (key && hash) touch(key, { hash, html: safe })
-          return { key: `${base}:${index}`, hash, html: safe, mode: block.mode } // kilocode_change
+          const hash = checksum(block.raw)
+          const safe = sanitize(await Promise.resolve(marked.parse(block.src)))
+          if (key && hash) touch(key, { raw: block.raw, hash, html: safe })
+          return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
         }),
       )
-        .then((blocks) => ({ content: blocks.map((block) => block.html).join(""), blocks })) // kilocode_change
-        .catch(() => ({ content: fallback(src.text), blocks: [] })) // kilocode_change
+        .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
+        .catch(
+          () =>
+            ({
+              text: src.text,
+              blocks: [
+                {
+                  key: base ?? "fallback",
+                  mode: "full" as const,
+                  raw: src.text,
+                  hash: checksum(src.text) ?? "",
+                  html: fallback(src.text),
+                },
+              ],
+            }) satisfies RenderResult,
+        )
+    },
+    {
+      initialValue: initialResult(local.text, local.cacheKey, projection(), owner),
     },
-    { initialValue: { content: fallback(local.text), blocks: [] } }, // kilocode_change
   )
 
   let copyCleanup: (() => void) | undefined
   // kilocode_change start: generation counter prevents stale deferredHighlight
   // callbacks from overwriting copyCleanup set by a newer render (issue #6221).
-  // The abort signal cancels the previous in-flight highlight pass so rapid
-  // streaming tokens don't spawn concurrent passes racing on the same DOM nodes.
   const highlightState = { gen: 0, signal: { aborted: false } }
   // kilocode_change end
 
@@ -308,52 +458,14 @@ export function Markdown(
   const mermaidState = { gen: 0, signal: { aborted: false } }
   // kilocode_change end
 
-  // kilocode_change start: rAF-coalesced morphdom render.
-  // During LLM token streaming, content updates arrive at 60–200Hz. Each
-  // token reparses the full accumulated HTML (temp.innerHTML = content) and
-  // diffs it via morphdom. CPU profile of a 7s streaming window showed 2,940
-  // ParseHTML events totaling ~619ms (~46% of blocked main-thread time). The
-  // user can only see one frame per 16ms anyway, so cap parses at ≤1 per
-  // animation frame.
-  let pendingFrame: number | undefined
-  let pendingContent: string | undefined
-  let pendingLabels: { copy: string; copied: string } | undefined
-  // kilocode_change end
-  // kilocode_change start
-  const incremental = createIncrementalMarkdown(decorate, {
-    cancel: () => {
-      if (pendingFrame === undefined) return
-      cancelAnimationFrame(pendingFrame)
-      pendingFrame = undefined
-      pendingContent = undefined
-      pendingLabels = undefined
-    },
-    ready: (container, labels, mermaid) => {
-      copyCleanup ??= setupCodeCopy(container, () => labels)
-      kickMermaid(container, true, mermaid)
-      kickHighlight(container, labels)
-    },
-  })
-  // kilocode_change end
-
   createEffect(() => {
     const container = root()
-    const rendered = html.latest ?? html() ?? { content: "", blocks: [] } // kilocode_change
-    const content = local.text ? rendered.content : "" // kilocode_change
+    const result = html.latest ?? html()
+    const projected = projection()
+    const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
     if (!container) return
     if (isServer) return
-
-    if (!content) {
-      // kilocode_change start: cancel any in-flight coalesced render so a
-      // clear takes precedence over a pending parse.
-      if (pendingFrame !== undefined) {
-        cancelAnimationFrame(pendingFrame)
-        pendingFrame = undefined
-        pendingContent = undefined
-        pendingLabels = undefined
-      }
-      // kilocode_change end
-      incremental.reset() // kilocode_change
+    if (content.length === 0) {
       container.innerHTML = ""
       // kilocode_change start: Mermaid diagram rendering
       mermaidState.signal.aborted = true
@@ -366,8 +478,28 @@ export function Markdown(
       copy: i18n.t("ui.message.copy"),
       copied: i18n.t("ui.message.copied"),
     }
+    const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key))
+    activeCodeKeys.forEach((key) => {
+      if (!nextCodeKeys.has(key)) disposeCode(key)
+    })
+    activeCodeKeys.clear()
+    nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
+    content.forEach((block, index) => updateBlock(container, index, block, labels, local.streaming ?? false)) // kilocode_change
+    while (container.children.length > content.length) container.lastElementChild?.remove()
+    container
+      .querySelectorAll('[data-slot="markdown-copy-button"]')
+      .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
+    if (!copyCleanup)
+      copyCleanup = setupCodeCopy(container, () => ({
+        copy: i18n.t("ui.message.copy"),
+        copied: i18n.t("ui.message.copied"),
+      }))
 
-    // kilocode_change start: Mermaid diagram rendering
+    // kilocode_change start: progressive Shiki highlighting for non-streaming
+    // "full" blocks and Mermaid diagram rendering. The parser emits plain
+    // 
 blocks; deferredHighlight upgrades them
+    // via setTimeout(0) so initial paint is instant. Mermaid blocks are
+    // detected and rendered as SVG diagrams when not streaming.
     const mermaid = {
       rendering: i18n.t("ui.mermaid.rendering"),
       renderError: (message: string) => i18n.t("ui.mermaid.renderError", { message }),
@@ -382,102 +514,8 @@ export function Markdown(
       downloadSvg: i18n.t("ui.mermaid.downloadSvg"),
       downloadPng: i18n.t("ui.mermaid.downloadPng"),
     }
-    // kilocode_change end
-
-    // kilocode_change start
-    const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup)
-    if (fast.handled) {
-      // Fast path took over; drop any pending coalesced morphdom from a
-      // previous streaming turn on this same element.
-      if (pendingFrame !== undefined) {
-        cancelAnimationFrame(pendingFrame)
-        pendingFrame = undefined
-        pendingContent = undefined
-        pendingLabels = undefined
-      }
-      incremental.reset() // kilocode_change
-      copyCleanup = fast.copyCleanup
-      kickMermaid(container, local.streaming ?? false, mermaid)
-      kickHighlight(container, labels)
-      return
-    }
-    // kilocode_change end
-
-    if (incremental.render(local.streaming ?? false, container, rendered.blocks, labels, mermaid)) return // kilocode_change
-    incremental.reset() // kilocode_change
-
-    // kilocode_change start: queue the latest content for a single rAF tick.
-    // Further updates before the frame runs simply overwrite pendingContent,
-    // so K rapid updates collapse to 1 parse instead of K.
-    pendingContent = content
-    pendingLabels = labels
-    if (pendingFrame !== undefined) return
-    pendingFrame = requestAnimationFrame(() => {
-      pendingFrame = undefined
-      const next = pendingContent
-      const nextLabels = pendingLabels
-      pendingContent = undefined
-      pendingLabels = undefined
-      if (next === undefined || nextLabels === undefined) return
-      if (!container.isConnected) return
-
-      const temp = document.createElement("div")
-      temp.innerHTML = next
-      decorate(temp, nextLabels)
-
-      // kilocode_change start: morphdom guard for highlighted blocks (issue #6221)
-      // During streaming, morphdom re-runs on every token. Without this guard,
-      // it would revert already-highlighted 
 blocks back to plain code.
-      morphdom(container, temp, {
-        childrenOnly: true,
-        onBeforeElUpdated: (fromEl, toEl) => {
-          if (
-            fromEl instanceof HTMLButtonElement &&
-            toEl instanceof HTMLButtonElement &&
-            fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
-            toEl.getAttribute("data-slot") === "markdown-copy-button" &&
-            fromEl.getAttribute("data-copied") === "true"
-          ) {
-            setCopyState(toEl, nextLabels, true)
-          }
-          if (fromEl.isEqualNode(toEl)) return false
-          // kilocode_change start: preserve rendered Mermaid diagrams across
-          // normal markdown morphdom refreshes so they do not flicker back to
-          // their source code while being re-rendered.
-          if (preserveMermaid(fromEl, toEl)) return false
-          // kilocode_change end
-          // Preserve Shiki-highlighted blocks — don't let morphdom revert them
-          // to plain 
 during streaming re-renders.
-          // Note: "shiki" class is on 
 (set by Shiki's codeToHtml output).
-          // We compare data-source-hash (a lightweight FNV-1a hash stored by
-          // deferredHighlight on the highlighted 
) against a hash of the
-          // incoming code text to detect mid-stream content changes: if the code
-          // changed, we let morphdom update so the block can be re-queued for
-          // highlighting with the new content.
-          if (
-            fromEl instanceof HTMLElement &&
-            fromEl.tagName === "PRE" &&
-            fromEl.classList.contains("shiki") &&
-            toEl instanceof HTMLElement &&
-            toEl.tagName === "PRE" &&
-            !toEl.classList.contains("shiki")
-          ) {
-            const fromHash = fromEl.getAttribute("data-source-hash")
-            const toCode = toEl.querySelector("code")?.textContent ?? ""
-            if (fromHash === fnv1a(toCode)) return false
-            if (preserveStreamingHighlight(fromEl, toEl, local.streaming ?? false)) return false // kilocode_change
-            // Source changed during streaming — fall through so morphdom replaces // kilocode_change
-            // the stale highlighted block with the updated plain block, which will
-            // be re-highlighted on the next deferredHighlight pass.
-          }
-          return true
-        },
-      })
-      // kilocode_change end
-
-      kickMermaid(container, local.streaming ?? false, mermaid) // kilocode_change
-      kickHighlight(container, nextLabels)
-    })
+    kickHighlight(container, labels)
+    kickMermaid(container, local.streaming ?? false, mermaid)
     // kilocode_change end
   })
 
@@ -530,15 +568,9 @@ export function Markdown(
     mermaidState.signal.aborted = true
     mermaidState.gen++
     // kilocode_change end
-    // kilocode_change: cancel any queued rAF parse so it doesn't touch the
-    // unmounted DOM after dispose.
-    if (pendingFrame !== undefined) {
-      cancelAnimationFrame(pendingFrame)
-      pendingFrame = undefined
-      pendingContent = undefined
-      pendingLabels = undefined
-    }
     if (copyCleanup) copyCleanup()
+    activeCodeKeys.forEach(disposeCode)
+    completedCode.clear()
   })
 
   return (
@@ -554,3 +586,211 @@ export function Markdown(
     />
   )
 }
+
+function pendingBlocks(
+  result: RenderResult | undefined,
+  projection: Projection | undefined,
+  cacheKey: string | undefined,
+  owner: string,
+) {
+  if (!result) return []
+  if (!projection || result.text === projection.text) return result.blocks
+  const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
+  return projection.blocks.map((block, index) => {
+    const current = initial ? undefined : result.blocks[index]
+    if (current && canReusePendingBlock(current, block)) return current
+    const key = markdownBlockKey(owner, cacheKey, index, block.mode)
+    if (block.mode !== "code")
+      return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) }
+    return {
+      key,
+      mode: block.mode,
+      raw: block.raw,
+      src: block.src, // kilocode_change
+      hash: String(block.raw.length),
+      language: block.language ?? "text",
+      complete: !!block.complete,
+      stable: [],
+      generation: 0,
+      unstable: [[block.src, ""] as MarkdownToken],
+    }
+  })
+}
+
+function disposeCode(key: string) {
+  disposeStreamingCode(key)
+}
+
+function updateBlock(
+  container: HTMLDivElement,
+  index: number,
+  block: RenderedBlock,
+  labels: CopyLabels,
+  streaming: boolean, // kilocode_change
+) {
+  const current = container.children[index]
+  if (block.mode === "code") {
+    updateCodeBlock(container, current, block, labels)
+    return
+  }
+  if (
+    current instanceof HTMLDivElement &&
+    current.dataset.markdownKey === block.key &&
+    current.dataset.markdownHash === block.hash
+  )
+    return
+
+  const next = document.createElement("div")
+  next.dataset.markdownBlock = ""
+  next.dataset.markdownKey = block.key
+  next.dataset.markdownHash = block.hash
+  next.style.display = "contents"
+  next.innerHTML = block.html
+  decorate(next, labels)
+
+  if (!(current instanceof HTMLDivElement)) {
+    container.appendChild(next)
+    return
+  }
+
+  morphdom(current, next, {
+    onBeforeElUpdated: (fromEl, toEl) => {
+      if (
+        fromEl instanceof HTMLButtonElement &&
+        toEl instanceof HTMLButtonElement &&
+        fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
+        toEl.getAttribute("data-slot") === "markdown-copy-button"
+      ) {
+        // kilocode_change start: preserve "copied" visual state across re-renders
+        if (fromEl.getAttribute("data-copied") === "true") setCopyState(toEl, labels, true)
+        // kilocode_change end
+        return false
+      }
+      if (fromEl.isEqualNode(toEl)) return false
+      // kilocode_change start: preserve rendered Mermaid diagrams across
+      // morphdom refreshes so they do not flicker back to source code.
+      if (preserveMermaid(fromEl, toEl)) return false
+      // kilocode_change end
+      // kilocode_change start: preserve Shiki-highlighted blocks — don't let
+      // morphdom revert them to plain 
 during streaming re-renders.
+      // Compare data-source-hash (stored by deferredHighlight on the highlighted
+      // 
) against a hash of the incoming code text to detect mid-stream
+      // content changes: if the code changed, let morphdom update so the block
+      // can be re-queued for highlighting.
+      if (
+        fromEl instanceof HTMLElement &&
+        fromEl.tagName === "PRE" &&
+        fromEl.classList.contains("shiki") &&
+        toEl instanceof HTMLElement &&
+        toEl.tagName === "PRE" &&
+        !toEl.classList.contains("shiki")
+      ) {
+        const fromHash = fromEl.getAttribute("data-source-hash")
+        const toCode = toEl.querySelector("code")?.textContent ?? ""
+        if (fromHash === fnv1a(toCode)) return false
+        if (preserveStreamingHighlight(fromEl, toEl, streaming)) return false
+      }
+      // kilocode_change end
+      return true
+    },
+  })
+}
+
+function updateCodeBlock(
+  container: HTMLDivElement,
+  current: Element | undefined,
+  block: Extract,
+  labels: CopyLabels,
+) {
+  const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined
+  const next = existing ?? document.createElement("div")
+  next.dataset.markdownBlock = ""
+  next.dataset.markdownKey = block.key
+  next.dataset.markdownHash = block.hash
+  next.dataset.markdownComplete = block.complete ? "true" : "false"
+  next.style.display = "contents"
+
+  // kilocode_change start: mermaid blocks render as a source 
 for
+  // kickMermaid to transform into SVG diagrams, not as Shiki-highlighted code.
+  if (block.language === "mermaid") {
+    next.replaceChildren()
+    const wrapper = document.createElement("div")
+    wrapper.setAttribute("data-component", "markdown-code")
+    const pre = document.createElement("pre")
+    pre.setAttribute("dir", "auto")
+    const codeElement = document.createElement("code")
+    codeElement.setAttribute("data-lang", "mermaid")
+    codeElement.textContent = block.src // kilocode_change - Mermaid rejects fenced Markdown as diagram source
+    pre.appendChild(codeElement)
+    wrapper.appendChild(pre)
+    wrapper.appendChild(createCopyButton(labels))
+    next.appendChild(wrapper)
+    if (current && current !== next) current.replaceWith(next)
+    else if (!current) container.appendChild(next)
+    return
+  }
+  // kilocode_change end
+
+  const code = existing?.querySelector("code")
+  if (code instanceof HTMLElement) {
+    code.className = `language-${block.language}`
+    const previous = renderedCodeTokens.get(next)
+    const reset = shouldResetCodeTokens(previous, {
+      language: block.language,
+      generation: block.generation,
+      stableCount: block.stable.length,
+      raw: block.raw,
+    })
+    const stableCount = reset ? 0 : previous!.stableCount
+    const tail = [...block.stable.slice(stableCount), ...block.unstable]
+    const prior = reset ? [] : previous!.unstable
+    const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index]))
+    const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix)
+    while (code.children.length > keep) code.lastElementChild?.remove()
+    tail
+      .slice(keep - stableCount)
+      .map(createTokenSpan)
+      .forEach((span) => code.appendChild(span))
+    renderedCodeTokens.set(next, {
+      language: block.language,
+      generation: block.generation,
+      stableCount: block.stable.length,
+      unstable: block.unstable,
+      raw: block.raw,
+    })
+    return
+  }
+
+  const wrapper = document.createElement("div")
+  wrapper.setAttribute("data-component", "markdown-code")
+  const pre = document.createElement("pre")
+  pre.className = "shiki Kilo"
+  pre.setAttribute("dir", "auto") // kilocode_change
+  const codeElement = document.createElement("code")
+  codeElement.className = `language-${block.language}`
+  ;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span))
+  pre.appendChild(codeElement)
+  wrapper.appendChild(pre)
+  wrapper.appendChild(createCopyButton(labels))
+  next.appendChild(wrapper)
+  renderedCodeTokens.set(next, {
+    language: block.language,
+    generation: block.generation,
+    stableCount: block.stable.length,
+    unstable: block.unstable,
+    raw: block.raw,
+  })
+  if (current) current.replaceWith(next)
+  else container.appendChild(next)
+}
+
+function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) {
+  return !!right && left[0] === right[0] && left[1] === right[1]
+}
+
+function createTokenSpan(token: MarkdownToken) {
+  const span = document.createElement("span")
+  span.setAttribute("style", token[1])
+  span.textContent = token[0]
+  return span
+}
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 4e75d22d12..37f6a94600 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -180,6 +180,7 @@ export interface MessagePartProps {
   onToolOpenChange?: (open: boolean) => void
   deferToolContent?: boolean
   virtualizeDiff?: boolean
+  onContentRendered?: () => void
   showAssistantCopyPartID?: string | null
   turnDurationMs?: number
 }
@@ -189,13 +190,14 @@ export type PartComponent = Component
 export const PART_MAPPING: Record = {}
 
 const TEXT_RENDER_PACE_MS = 24
+const TEXT_RENDER_IMMEDIATE = 512
 const TEXT_RENDER_SNAP = /[\s.,!?;:)\]]/
 
 function step(size: number) {
   if (size <= 12) return 2
   if (size <= 48) return 4
   if (size <= 96) return 8
-  return Math.min(24, Math.ceil(size / 8))
+  return Math.min(256, Math.ceil(size / 4))
 }
 
 function next(text: string, start: number) {
@@ -234,6 +236,10 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
       sync(text)
       return
     }
+    if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
+      sync(text)
+      return
+    }
     const end = next(text, shown.length)
     sync(text.slice(0, end))
     if (end < text.length) timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
@@ -251,6 +257,11 @@ function createPacedValue(getValue: () => string, live?: () => boolean) {
       sync(text)
       return
     }
+    if (text.length - shown.length <= TEXT_RENDER_IMMEDIATE) {
+      clear()
+      sync(text)
+      return
+    }
     if (text.length === shown.length || timeout) return
     timeout = setTimeout(run, TEXT_RENDER_PACE_MS)
   })
@@ -1291,6 +1302,7 @@ export function Part(props: MessagePartProps) {
         onToolOpenChange={props.onToolOpenChange}
         deferToolContent={props.deferToolContent}
         virtualizeDiff={props.virtualizeDiff}
+        onContentRendered={props.onContentRendered}
         showAssistantCopyPartID={props.showAssistantCopyPartID}
         turnDurationMs={props.turnDurationMs}
       />
@@ -1311,6 +1323,7 @@ export interface ToolProps {
   onOpenChange?: (open: boolean) => void
   deferContent?: boolean
   virtualizeDiff?: boolean
+  onContentRendered?: () => void
   forceOpen?: boolean
   locked?: boolean
 }
@@ -1457,6 +1470,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
               onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
               deferContent={props.deferToolContent}
               virtualizeDiff={props.virtualizeDiff}
+              onContentRendered={props.onContentRendered}
             />
           
         
@@ -2058,7 +2072,13 @@ ToolRegistry.register({
               }
             >
               
- +
@@ -2117,6 +2137,7 @@ ToolRegistry.register({ cacheKey: checksum(props.input.content), }} overflow="scroll" + onRendered={props.onContentRendered} />
@@ -2245,6 +2266,7 @@ ToolRegistry.register({ virtualize={props.virtualizeDiff} fileDiff={file.view.fileDiff} hunkSeparators={file.view.fileDiff.isPartial ? "simple" : "line-info-basic"} + onRendered={props.onContentRendered} /> @@ -2320,6 +2342,7 @@ ToolRegistry.register({ mode="diff" virtualize={props.virtualizeDiff} fileDiff={single()!.view.fileDiff} + onRendered={props.onContentRendered} /> diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts index d28b51fea8..370d025aa6 100644 --- a/packages/ui/src/components/scroll-view.test.ts +++ b/packages/ui/src/components/scroll-view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { scrollKey } from "./scroll-view" +import { scrollKey, scrollTopFromThumbPointer } from "./scroll-view" describe("scrollKey", () => { test("maps plain navigation keys", () => { @@ -17,3 +17,38 @@ describe("scrollKey", () => { expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined() }) }) + +describe("scrollTopFromThumbPointer", () => { + test("keeps downward thumb movement monotonic when content height changes", () => { + const first = scrollTopFromThumbPointer({ + pointer: 300, + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 6_000, + thumbHeight: 60, + }) + const second = scrollTopFromThumbPointer({ + pointer: 320, + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 60_000, + thumbHeight: 32, + }) + + expect(second).toBeGreaterThan(first) + }) + + test("clamps pointer positions to the scroll range", () => { + const input = { + viewportTop: 100, + grabOffset: 12, + clientHeight: 600, + scrollHeight: 6_000, + thumbHeight: 60, + } + expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) + expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index 3ff00f117d..9944da77d3 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -27,6 +27,21 @@ export const scrollKey = (event: Pick { e.preventDefault() e.stopPropagation() setState("isDragging", true) - startY = e.clientY - startScrollTop = viewportRef.scrollTop + const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top thumbRef.setPointerCapture(e.pointerId) const onPointerMove = (e: PointerEvent) => { - const deltaY = e.clientY - startY const { scrollHeight, clientHeight } = viewportRef - const maxScrollTop = scrollHeight - clientHeight - const maxThumbTop = clientHeight - thumbHeight() - - if (maxThumbTop > 0) { - const scrollDelta = deltaY * (maxScrollTop / maxThumbTop) - viewportRef.scrollTop = startScrollTop + scrollDelta - } + viewportRef.scrollTop = scrollTopFromThumbPointer({ + pointer: e.clientY, + viewportTop: viewportRef.getBoundingClientRect().top, + grabOffset, + clientHeight, + scrollHeight, + thumbHeight: thumbHeight(), + }) } - const onPointerUp = (e: PointerEvent) => { + const done = (e: PointerEvent) => { setState("isDragging", false) thumbRef.releasePointerCapture(e.pointerId) thumbRef.removeEventListener("pointermove", onPointerMove) - thumbRef.removeEventListener("pointerup", onPointerUp) + thumbRef.removeEventListener("pointerup", done) + thumbRef.removeEventListener("pointercancel", done) } thumbRef.addEventListener("pointermove", onPointerMove) - thumbRef.addEventListener("pointerup", onPointerUp) + thumbRef.addEventListener("pointerup", done) + thumbRef.addEventListener("pointercancel", done) } // Keybinds implementation diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 33af8ff08e..5c11bdce5a 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -12,8 +12,8 @@ import type { MarkedExtension, TokenizerAndRendererExtension } from "marked" import { bundledLanguages, type BundledLanguage } from "shiki" import { parseFilePath } from "../file-path" // kilocode_change import { createSimpleContext } from "./helper" -import { getSharedHighlighter } from "@pierre/diffs" // kilocode_change -import { ensureKiloDiffTheme } from "../pierre/kilo-diff-theme" // kilocode_change +import { getSharedHighlighter, type ThemeRegistrationResolved } from "@pierre/diffs" // kilocode_change +import { ensureKiloDiffTheme, KILO_DIFF_THEME } from "../pierre/kilo-diff-theme" // kilocode_change // kilocode_change start: the "Kilo" diff/highlight theme registration moved to // ../pierre/kilo-diff-theme so the diff worker pool can register it without @@ -24,6 +24,382 @@ import { ensureKiloDiffTheme } from "../pierre/kilo-diff-theme" // kilocode_chan ensureKiloDiffTheme() // kilocode_change end +// kilocode_change start: theme object consumed by the streaming Shiki worker +// (markdown-worker.ts sends it via postMessage on worker init). Registration +// with Pierre is handled by ensureKiloDiffTheme() above; this export only +// provides the raw theme data to the worker. +export const KiloTheme = { + name: KILO_DIFF_THEME, + bg: "var(--color-background-stronger)", + fg: "var(--text-base)", + colors: { + "editor.background": "var(--color-background-stronger)", + "editor.foreground": "var(--text-base)", + "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", + "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", + // "gitDecoration.conflictingResourceForeground": "#ffca00", + // "gitDecoration.modifiedResourceForeground": "#1a76d4", + // "gitDecoration.untrackedResourceForeground": "#00cab1", + // "gitDecoration.ignoredResourceForeground": "#84848A", + // "terminal.titleForeground": "#adadb1", + // "terminal.titleInactiveForeground": "#84848A", + // "terminal.background": "#141415", + // "terminal.foreground": "#adadb1", + // "terminal.ansiBlack": "#141415", + // "terminal.ansiRed": "#ff2e3f", + // "terminal.ansiGreen": "#0dbe4e", + // "terminal.ansiYellow": "#ffca00", + // "terminal.ansiBlue": "#008cff", + // "terminal.ansiMagenta": "#c635e4", + // "terminal.ansiCyan": "#08c0ef", + // "terminal.ansiWhite": "#c6c6c8", + // "terminal.ansiBrightBlack": "#141415", + // "terminal.ansiBrightRed": "#ff2e3f", + // "terminal.ansiBrightGreen": "#0dbe4e", + // "terminal.ansiBrightYellow": "#ffca00", + // "terminal.ansiBrightBlue": "#008cff", + // "terminal.ansiBrightMagenta": "#c635e4", + // "terminal.ansiBrightCyan": "#08c0ef", + // "terminal.ansiBrightWhite": "#c6c6c8", + }, + tokenColors: [ + { + scope: ["comment", "punctuation.definition.comment", "string.comment"], + settings: { + foreground: "var(--syntax-comment)", + }, + }, + { + scope: ["entity.other.attribute-name"], + settings: { + foreground: "var(--syntax-property)", // maybe attribute + }, + }, + { + scope: ["constant", "entity.name.constant", "variable.other.constant", "variable.language", "entity"], + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: ["entity.name", "meta.export.default", "meta.definition.variable"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: ["meta.object.member"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "variable.parameter.function", + "meta.jsx.children", + "meta.block", + "meta.tag.attributes", + "entity.name.constant", + "meta.embedded.expression", + "meta.template.expression", + "string.other.begin.yaml", + "string.other.end.yaml", + ], + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["entity.name.function", "support.type.primitive"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.class.component"], + settings: { + foreground: "var(--syntax-type)", + }, + }, + { + scope: "keyword", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: [ + "keyword.operator", + "storage.type.function.arrow", + "punctuation.separator.key-value.css", + "entity.name.tag.yaml", + "punctuation.separator.key-value.mapping.yaml", + ], + settings: { + foreground: "var(--syntax-operator)", + }, + }, + { + scope: ["storage", "storage.type"], + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: ["storage.modifier.package", "storage.modifier.import", "storage.type.java"], + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: [ + "string", + "punctuation.definition.string", + "string punctuation.section.embedded source", + "entity.name.tag", + ], + settings: { + foreground: "var(--syntax-string)", + }, + }, + { + scope: "support", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: ["support.type.object.module", "variable.other.object", "support.type.property-name.css"], + settings: { + foreground: "var(--syntax-object)", + }, + }, + { + scope: "meta.property-name", + settings: { + foreground: "var(--syntax-property)", + }, + }, + { + scope: "variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "variable.other", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: [ + "invalid.broken", + "invalid.illegal", + "invalid.unimplemented", + "invalid.deprecated", + "message.error", + "markup.deleted", + "meta.diff.header.from-file", + "punctuation.definition.deleted", + "brackethighlighter.unmatched", + "token.error-token", + ], + settings: { + foreground: "var(--syntax-critical)", + }, + }, + { + scope: "carriage-return", + settings: { + foreground: "var(--syntax-keyword)", + }, + }, + { + scope: "string source", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "string variable", + settings: { + foreground: "var(--syntax-constant)", + }, + }, + { + scope: [ + "source.regexp", + "string.regexp", + "string.regexp.character-class", + "string.regexp constant.character.escape", + "string.regexp source.ruby.embedded", + "string.regexp string.regexp.arbitrary-repitition", + "string.regexp constant.character.escape", + ], + settings: { + foreground: "var(--syntax-regexp)", + }, + }, + { + scope: "support.constant", + settings: { + foreground: "var(--syntax-primitive)", + }, + }, + { + scope: "support.variable", + settings: { + foreground: "var(--syntax-variable)", + }, + }, + { + scope: "meta.module-reference", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "punctuation.definition.list.begin.markdown", + settings: { + foreground: "var(--syntax-punctuation)", + }, + }, + { + scope: ["markup.heading", "markup.heading entity.name"], + settings: { + fontStyle: "bold", + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.quote", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "markup.italic", + settings: { + fontStyle: "italic", + // foreground: "", + }, + }, + { + scope: "markup.bold", + settings: { + fontStyle: "bold", + foreground: "var(--text-strong)", + }, + }, + { + scope: [ + "markup.raw", + "markup.inserted", + "meta.diff.header.to-file", + "punctuation.definition.inserted", + "markup.changed", + "punctuation.definition.changed", + "markup.ignored", + "markup.untracked", + ], + settings: { + foreground: "var(--text-base)", + }, + }, + { + scope: "meta.diff.range", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.diff.header", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.separator", + settings: { + fontStyle: "bold", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.output", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "meta.export.default", + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: [ + "brackethighlighter.tag", + "brackethighlighter.curly", + "brackethighlighter.round", + "brackethighlighter.square", + "brackethighlighter.angle", + "brackethighlighter.quote", + ], + settings: { + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: ["constant.other.reference.link", "string.other.link"], + settings: { + fontStyle: "underline", + foreground: "var(--syntax-unknown)", + }, + }, + { + scope: "token.info-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + { + scope: "token.warn-token", + settings: { + foreground: "var(--syntax-warning)", + }, + }, + { + scope: "token.debug-token", + settings: { + foreground: "var(--syntax-info)", + }, + }, + ], + semanticTokenColors: { + comment: "var(--syntax-comment)", + string: "var(--syntax-string)", + number: "var(--syntax-constant)", + regexp: "var(--syntax-regexp)", + keyword: "var(--syntax-keyword)", + variable: "var(--syntax-variable)", + parameter: "var(--syntax-variable)", + property: "var(--syntax-property)", + function: "var(--syntax-primitive)", + method: "var(--syntax-primitive)", + type: "var(--syntax-type)", + class: "var(--syntax-type)", + namespace: "var(--syntax-type)", + enumMember: "var(--syntax-primitive)", + "variable.constant": "var(--syntax-constant)", + "variable.defaultLibrary": "var(--syntax-unknown)", + }, +} as unknown as ThemeRegistrationResolved +// kilocode_change end + // kilocode_change start: double-dollar-only math rules for marked. const BLOCK = /^\$\$\n((?:\\[^]|[^\\])+?)\n\$\$(?:\n|$)/ const INLINE = /^\$\$(?!\$)((?:\\.|[^\\\n])*?(?:\\.|[^\\\n$]))\$\$/ diff --git a/packages/ui/src/kilocode/markdown-bidi.test.ts b/packages/ui/src/kilocode/markdown-bidi.test.ts index 4382f41e6c..906f6ad6fc 100644 --- a/packages/ui/src/kilocode/markdown-bidi.test.ts +++ b/packages/ui/src/kilocode/markdown-bidi.test.ts @@ -33,12 +33,20 @@ describe("Markdown bidirectional rendering contract", () => { useMarked: () => ({ parse: async () => "" }), deferredHighlight: async () => {}, fnv1a: (text) => text, + KiloTheme: { name: "Kilo" }, })) mock.module("./src/kilocode/markdown-mermaid", () => ({ hasMermaid: () => false, preserveMermaid: () => false, renderMermaid: async () => {}, })) + mock.module("./src/components/markdown-worker", () => ({ + disposeStreamingCode: () => {}, + highlightStreamingCode: async () => { throw new Error("unexpected worker call") }, + MarkdownWorkerDisposedError: class extends Error {}, + MarkdownWorkerSupersededError: class extends Error {}, + MarkdownWorkerUnavailableError: class extends Error {}, + })) const { Markdown } = await import("./src/components/markdown") console.log(renderToString(() => createComponent(Markdown, { text: "hello" }))) diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.test.ts b/packages/ui/src/kilocode/markdown-stable-blocks.test.ts deleted file mode 100644 index 3dcf759247..0000000000 --- a/packages/ui/src/kilocode/markdown-stable-blocks.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { marked } from "marked" -import remend from "remend" -import { stream } from "../components/markdown-stream" -import { stableBlocks } from "./markdown-stable-blocks" - -async function render(text: string) { - const html = await Promise.all(stream(text, true).map((block) => Promise.resolve(marked.parse(block.src)))) - return html.join("") -} - -describe("stable markdown blocks", () => { - test("keeps completed top-level tokens stable and only heals the tail", () => { - expect( - stableBlocks( - [ - { type: "heading", raw: "# Title\n\n" }, - { type: "paragraph", raw: "First" }, - { type: "space", raw: "\n\n" }, - { type: "paragraph", raw: "Second **open" }, - ], - (raw) => `${raw}**`, - ), - ).toEqual([ - { raw: "# Title\n\n", src: "# Title\n\n", mode: "full" }, - { raw: "First\n\n", src: "First\n\n", mode: "full" }, - { raw: "Second **open", src: "Second **open**", mode: "live" }, - ]) - }) - - test("leaves a single mutable token on the existing streaming path", () => { - expect(stableBlocks([{ type: "paragraph", raw: "Still streaming" }], (raw) => raw)).toBeUndefined() - }) - - test("matches canonical streaming HTML for mixed completed blocks", async () => { - const text = [ - "# Report", - "", - "A completed paragraph with **emphasis**.", - "", - "- first item", - "- second item", - "", - "```ts", - "export const value = 1", - "```", - "", - "The final paragraph is *still streaming", - ].join("\n") - - expect(await render(text)).toBe(await marked.parse(remend(text, { linkMode: "text-only" }))) - expect(stream(text, true).map((block) => block.mode)).toEqual(["full", "full", "full", "full", "live"]) - }) -}) diff --git a/packages/ui/src/kilocode/markdown-stable-blocks.ts b/packages/ui/src/kilocode/markdown-stable-blocks.ts deleted file mode 100644 index 1878f9787d..0000000000 --- a/packages/ui/src/kilocode/markdown-stable-blocks.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { Block } from "../components/markdown-stream" - -type Token = { - type: string - raw: string -} - -export function stableBlocks(tokens: Token[], live: (raw: string) => string): Block[] | undefined { - const indexes = tokens.flatMap((token, index) => (token.type === "space" ? [] : [index])) - if (indexes.length < 2) return - - const raw = (start: number, end = tokens.length) => - tokens - .slice(start, end) - .map((token) => token.raw) - .join("") - // Completed top-level tokens keep stable hashes across stream updates. The - // existing parse and sanitize cache can then reuse them while only the tail changes. - const stable = indexes.slice(0, -1).map((start, index) => { - const value = raw(start, indexes[index + 1]) - return { raw: value, src: value, mode: "full" as const } - }) - const tail = raw(indexes.at(-1)!) - return [...stable, { raw: tail, src: live(tail), mode: "live" }] -} diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index 8696be3475..d751378f7d 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -1,6 +1,7 @@ import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs" import { ComponentProps } from "solid-js" import { lineCommentStyles } from "../components/line-comment-styles" +import { KILO_DIFF_THEME } from "./kilo-diff-theme" // kilocode_change export type DiffProps = FileDiffOptions & { before: FileContents @@ -17,24 +18,15 @@ export type DiffProps = FileDiffOptions & { const unsafeCSS = ` [data-diff], [data-file] { - --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); - --diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)))); - --diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)))); - --diffs-bg-context: var(--diffs-bg-context-override, light-dark( color-mix(in lab, var(--diffs-bg) 98.5%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92.5%, var(--diffs-mixer)))); - --diffs-bg-separator: var(--diffs-bg-separator-override, light-dark( color-mix(in lab, var(--diffs-bg) 96%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-mixer)))); - --diffs-fg: light-dark(var(--diffs-light), var(--diffs-dark)); - --diffs-fg-number: var(--diffs-fg-number-override, light-dark(color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)), color-mix(in lab, var(--diffs-fg) 65%, var(--diffs-bg)))); - --diffs-deletion-base: var(--syntax-diff-delete); - --diffs-addition-base: var(--syntax-diff-add); - --diffs-modified-base: var(--syntax-diff-unknown); - --diffs-bg-deletion: var(--diffs-bg-deletion-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-number: var(--diffs-bg-deletion-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-hover: var(--diffs-bg-deletion-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-deletion-base)), color-mix(in lab, var(--diffs-bg) 75%, var(--diffs-deletion-base)))); - --diffs-bg-deletion-emphasis: var(--diffs-bg-deletion-emphasis-override, light-dark(rgb(from var(--diffs-deletion-base) r g b / 0.7), rgb(from var(--diffs-deletion-base) r g b / 0.1))); - --diffs-bg-addition: var(--diffs-bg-addition-override, light-dark( color-mix(in lab, var(--diffs-bg) 98%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-addition-base)))); - --diffs-bg-addition-number: var(--diffs-bg-addition-number-override, light-dark( color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 85%, var(--diffs-addition-base)))); - --diffs-bg-addition-hover: var(--diffs-bg-addition-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 80%, var(--diffs-addition-base)), color-mix(in lab, var(--diffs-bg) 70%, var(--diffs-addition-base)))); - --diffs-bg-addition-emphasis: var(--diffs-bg-addition-emphasis-override, light-dark(rgb(from var(--diffs-addition-base) r g b / 0.07), rgb(from var(--diffs-addition-base) r g b / 0.1))); + /* Pierre 1.2 mixes these override targets at 12% in light mode and 20% in dark mode. */ + --diffs-bg-deletion-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-deletion-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-deletion-base)) + ); + --diffs-bg-addition-override: light-dark( + color-mix(in lab, var(--diffs-bg) 33.333%, var(--diffs-addition-base)), + color-mix(in lab, var(--diffs-bg) 60%, var(--diffs-addition-base)) + ); --diffs-selection-base: var(--surface-warning-strong); --diffs-selection-border: var(--border-warning-base); --diffs-selection-number-fg: #1c1917; @@ -143,7 +135,7 @@ const unsafeCSS = ` } &[data-interactive-line-numbers] [data-column-number] { - cursor: pointer !important; + cursor: default !important; } &[data-interactive-lines] [data-line] { @@ -161,7 +153,7 @@ ${lineCommentStyles} export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) { return { - theme: "Kilo", + theme: KILO_DIFF_THEME, // kilocode_change themeType: "system", disableLineNumbers: false, overflow: "wrap", diff --git a/packages/ui/src/pierre/kilo-diff-theme.ts b/packages/ui/src/pierre/kilo-diff-theme.ts index 7f19c3cd64..4fd5ff652c 100644 --- a/packages/ui/src/pierre/kilo-diff-theme.ts +++ b/packages/ui/src/pierre/kilo-diff-theme.ts @@ -1,5 +1,5 @@ // kilocode_change - new file -import { registerCustomTheme, RegisteredCustomThemes, type ThemeRegistrationResolved } from "@pierre/diffs" +import { registerCustomTheme, type ThemeRegistrationResolved } from "@pierre/diffs" // The "Kilo" Pierre/Shiki theme used by every diff review surface (Code / Diff / // File / SessionReview) and by markdown code highlighting. Pierre resolves the @@ -23,12 +23,23 @@ import { registerCustomTheme, RegisteredCustomThemes, type ThemeRegistrationReso export const KILO_DIFF_THEME = "Kilo" +const registrations = (() => { + const key = Symbol.for("kilocode.ui.pierre.kilo-diff-theme") + const existing = Reflect.get(globalThis, key) + if (existing instanceof WeakSet) return existing as WeakSet + + const value = new WeakSet() + Reflect.set(globalThis, key, value) + return value +})() + // Idempotent: this is reached from both the markdown context and the diff worker -// factory. Guard against the authoritative Pierre registry (rather than a local -// flag) so it stays a no-op even if this module is instantiated more than once -// across separate bundles — @pierre/diffs otherwise logs an error on duplicates. +// factory. Pierre no longer exposes its registered-theme set, so use a realm-wide +// guard keyed by the public registration function. Duplicate Kilo modules sharing +// one Pierre instance stay no-ops, while separately bundled Pierre instances still +// receive their own registration. export function ensureKiloDiffTheme(): void { - if (RegisteredCustomThemes.has(KILO_DIFF_THEME)) return + if (registrations.has(registerCustomTheme)) return registerCustomTheme(KILO_DIFF_THEME, () => { return Promise.resolve({ @@ -38,6 +49,7 @@ export function ensureKiloDiffTheme(): void { "editor.foreground": "var(--text-base)", "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", + "gitDecoration.modifiedResourceForeground": "var(--syntax-diff-unknown)", // "gitDecoration.conflictingResourceForeground": "#ffca00", // "gitDecoration.modifiedResourceForeground": "#1a76d4", // "gitDecoration.untrackedResourceForeground": "#00cab1", @@ -399,4 +411,5 @@ export function ensureKiloDiffTheme(): void { }, } as unknown as ThemeRegistrationResolved) }) + registrations.add(registerCustomTheme) } diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts index 31862cc493..235a3fd677 100644 --- a/packages/ui/src/pierre/virtualizer.ts +++ b/packages/ui/src/pierre/virtualizer.ts @@ -16,7 +16,7 @@ const cache = new WeakMap() export const virtualMetrics: Partial = { lineHeight: 24, hunkSeparatorHeight: 24, - fileGap: 0, + spacing: 0, } function scrollable(value: string) { diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index e75e4c7ade..c4a3df9115 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -137,6 +137,7 @@ [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { background-color: var(--v2-overlay-simple-overlay-pressed); + color: var(--v2-icon-icon-base); } [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) { diff --git a/packages/ui/sst-env.d.ts b/packages/ui/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/ui/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index d17c700124..ae25202873 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -1,3 +1,29 @@ +diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/cjs/client/index.d.ts ++++ b/dist/cjs/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ +diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/esm/client/index.d.ts ++++ b/dist/esm/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 --- a/dist/cjs/client/index.js diff --git a/patches/@pierre%2Ftrees@1.0.0-beta.4.patch b/patches/@pierre%2Ftrees@1.0.0-beta.4.patch new file mode 100644 index 0000000000..b9db9cbf79 --- /dev/null +++ b/patches/@pierre%2Ftrees@1.0.0-beta.4.patch @@ -0,0 +1,107 @@ +diff --git a/dist/model/FileTreeController.js b/dist/model/FileTreeController.js +index 37c5d1ffedd5aff0258dec41b1b5cedb240aa3a6..365c42ef4c824bfd13d14f0d09226929af625c73 100644 +--- a/dist/model/FileTreeController.js ++++ b/dist/model/FileTreeController.js +@@ -1052,8 +1052,8 @@ var FileTreeController = class { + this.#setExpandedPaths(expandedPaths); + return focusCandidate ?? this.#focusedPath; + } +- #emit() { +- for (const listener of this.#listeners) listener(); ++ #emit(event) { ++ for (const listener of this.#listeners) listener(event); + } + #emitMutation(event) { + this.#mutationListeners.get(event.operation)?.forEach((listener) => { +@@ -1149,7 +1149,7 @@ var FileTreeController = class { + const searchFocusCandidate = this.#searchValue != null && this.#searchValue.length > 0 ? this.#refreshActiveSearchState() : this.#searchValue === "" ? this.#focusedPath : focusPathCandidate; + const shouldBuildFullProjection = this.#searchValue != null || event.operation !== "expand" && event.operation !== "collapse"; + this.#rebuildVisibleProjection(searchFocusCandidate, shouldBuildFullProjection); +- this.#emit(); ++ this.#emit(event); + const mutationEvent = toTreesMutationEvent(event); + if (mutationEvent != null) this.#emitMutation(mutationEvent); + }); +diff --git a/dist/model/internalTypes.d.ts b/dist/model/internalTypes.d.ts +index 5b0fdd5a3b483439ae256795f3dad5a32182ab78..073c6f21edc20ce5bd44b122fe02b83de802b887 100644 +--- a/dist/model/internalTypes.d.ts ++++ b/dist/model/internalTypes.d.ts +@@ -4,7 +4,10 @@ import { FileTreeCompositionOptions, FileTreePublicId, FileTreeRenderOptions, Fi + import { FileTreeController } from "./FileTreeController.js"; + + //#region src/model/internalTypes.d.ts +-type FileTreeControllerListener = () => void; ++type FileTreeControllerListener = (event?: { ++ operation: 'expand' | 'collapse'; ++ path: string; ++}) => void; + interface FileTreeStickyRowCandidate { + row: FileTreeVisibleRow; + subtreeEndIndex: number; +diff --git a/dist/model/publicTypes.d.ts b/dist/model/publicTypes.d.ts +index 84a7e2d14f67c1aad8230f19a3426ab7403f378e..752e262e6d2d8a836931b7b83a93d89a75e6209c 100644 +--- a/dist/model/publicTypes.d.ts ++++ b/dist/model/publicTypes.d.ts +@@ -167,6 +167,10 @@ type FileTreeOptionSurface = FileTreeRenderOptions & { + gitStatus?: readonly GitStatusEntry[]; + id?: string; + icons?: FileTreeIcons; ++ onExpansionChange?: (change: { ++ expanded: boolean; ++ path: FileTreePublicId; ++ }) => void; + onSelectionChange?: FileTreeSelectionChangeListener; + renderRowDecoration?: FileTreeRowDecorationRenderer; + search?: boolean; +diff --git a/dist/render/FileTree.js b/dist/render/FileTree.js +index 6db15e51b833192f79b71a15febe1612a4c185d0..36a4e6ac527595849c1db0e6bbebb475b6030b7b 100644 +--- a/dist/render/FileTree.js ++++ b/dist/render/FileTree.js +@@ -63,6 +63,7 @@ var FileTree = class { + #composition; + #controller; + #id; ++ #onExpansionChange; + #onSelectionChange; + #renderRowDecoration; + #renamingEnabled; +@@ -80,16 +81,18 @@ var FileTree = class { + #appliedUnsafeCSS; + #selectionVersion; + #selectionSubscription = null; ++ #expansionSubscription = null; + #wrapper; + #wroteHostItemHeight = false; + #wroteHostDensityFactor = false; + constructor(options) { +- const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options; ++ const { composition, density, fileTreeSearchMode, gitStatus, id, initialSearchQuery, icons, itemHeight, onExpansionChange, onSearchChange, onSelectionChange, overscan, renderRowDecoration, renaming, search, searchBlurBehavior, searchFakeFocus, stickyFolders, unsafeCSS, initialVisibleRowCount,...controllerOptions } = options; + this.#composition = composition; + this.#id = createClientId(id); + this.#gitStatusState = resolveFileTreeGitStatusState(gitStatus); + this.#icons = icons; + this.#unsafeCSS = unsafeCSS; ++ this.#onExpansionChange = onExpansionChange; + this.#onSelectionChange = onSelectionChange; + this.#renderRowDecoration = renderRowDecoration; + this.#renamingEnabled = renaming != null && renaming !== false; +@@ -114,6 +117,10 @@ var FileTree = class { + this.#selectionSubscription = this.#onSelectionChange == null ? null : this.subscribe(() => { + this.#emitSelectionChange(); + }); ++ this.#expansionSubscription = this.#onExpansionChange == null ? null : this.#controller.subscribe((event) => { ++ if (event?.operation !== "expand" && event?.operation !== "collapse") return; ++ this.#onExpansionChange?.({ path: event.path, expanded: event.operation === "expand" }); ++ }); + } + unmount() { + if (this.#wrapper != null) { +@@ -133,6 +140,8 @@ var FileTree = class { + this.unmount(); + this.#selectionSubscription?.(); + this.#selectionSubscription = null; ++ this.#expansionSubscription?.(); ++ this.#expansionSubscription = null; + this.#controller.destroy(); + } + getFileTreeContainer() { diff --git a/patches/@tanstack%2Fsolid-virtual@3.13.28.patch b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch new file mode 100644 index 0000000000..3b1cda91e1 --- /dev/null +++ b/patches/@tanstack%2Fsolid-virtual@3.13.28.patch @@ -0,0 +1,45 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index 7e97823ea769398ccd9cf449b178c77675ed252c..d75183f11421af0e20e4e8a996af99c300ad936d 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -39,7 +39,9 @@ function createVirtualizerBase(options) { + (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); + } + })); +- virtualizer.measure(); ++ virtualizer._willUpdate(); ++ setVirtualItems(store.reconcile(instance.getVirtualItems(), { key: "index" })); ++ setTotalSize(instance.getTotalSize()); + }); + return virtualizer; + } +diff --git a/dist/esm/index.js b/dist/esm/index.js +index 1d525463775fef3e8ece6ab191061ef9d0a36d73..14c680a2088c49a33959d8118cf32ee599ab83c2 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -38,7 +38,9 @@ function createVirtualizerBase(options) { + (_a = options.onChange) == null ? void 0 : _a.call(options, instance2, sync); + } + })); +- virtualizer.measure(); ++ virtualizer._willUpdate(); ++ setVirtualItems(reconcile(instance.getVirtualItems(), { key: "index" })); ++ setTotalSize(instance.getTotalSize()); + }); + return virtualizer; + } +diff --git a/src/index.tsx b/src/index.tsx +index 69ac34fd70753b9bd00683c2540be7f62630f8f2..9f16672aa0f4a044aa2b35754d385d7d8031f743 100644 +--- a/src/index.tsx ++++ b/src/index.tsx +@@ -81,7 +81,9 @@ function createVirtualizerBase< + }, + }), + ) +- virtualizer.measure() ++ virtualizer._willUpdate() ++ setVirtualItems(reconcile(instance.getVirtualItems(), { key: 'index' })) ++ setTotalSize(instance.getTotalSize()) + }) + + return virtualizer diff --git a/patches/@tanstack%2Fvirtual-core@3.17.0.patch b/patches/@tanstack%2Fvirtual-core@3.17.0.patch new file mode 100644 index 0000000000..388f8855a5 --- /dev/null +++ b/patches/@tanstack%2Fvirtual-core@3.17.0.patch @@ -0,0 +1,58 @@ +diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs +index df75d0cf0347b62906e04e454d4f4ef062ed5c48..58913f2d30e0beee9d09dffa5ebcaab4601a2c22 100644 +--- a/dist/cjs/index.cjs ++++ b/dist/cjs/index.cjs +@@ -526,6 +526,7 @@ class Virtualizer { + this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); + return this.scrollOffset; + }; ++ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; + this.getFurthestMeasurement = (measurements, index) => { + const furthestMeasurementsFound = /* @__PURE__ */ new Map(); + const furthestMeasurements = /* @__PURE__ */ new Map(); +diff --git a/dist/cjs/index.d.cts b/dist/cjs/index.d.cts +index c61ee17752565253f795c7fc7d57e86237ecbb52..705bb7e3a121b040fb1a3e7890179eaa3e9b219e 100644 +--- a/dist/cjs/index.d.cts ++++ b/dist/cjs/index.d.cts +@@ -108,6 +108,7 @@ export declare class Virtualizer number; + private scrollAdjustments; + private _iosDeferredAdjustment; + private _iosTouching; +diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts +index b03abab604eb6578f6f56ff92c489259cfaf8f19..0495f372ea000dffc416c4f56809946f7ba73099 100644 +--- a/dist/esm/index.d.ts ++++ b/dist/esm/index.d.ts +@@ -108,6 +108,7 @@ export declare class Virtualizer number; + private scrollAdjustments; + private _iosDeferredAdjustment; + private _iosTouching; +diff --git a/dist/esm/index.js b/dist/esm/index.js +index e384cf7541978a2782b9dca68146e869b16ac3f2..53b15b7a36247ea4aa5b70b00d08b6d7dec3ea79 100644 +--- a/dist/esm/index.js ++++ b/dist/esm/index.js +@@ -524,6 +524,7 @@ class Virtualizer { + this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); + return this.scrollOffset; + }; ++ this.getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments; + this.getFurthestMeasurement = (measurements, index) => { + const furthestMeasurementsFound = /* @__PURE__ */ new Map(); + const furthestMeasurements = /* @__PURE__ */ new Map(); +diff --git a/src/index.ts b/src/index.ts +index d35b3e0695a9c85b261bc1a4fbe23c0a60d5b204..d516a9d00f1233173f9a3e2144666c8159552f91 100644 +--- a/src/index.ts ++++ b/src/index.ts +@@ -1050,3 +1050,5 @@ export class Virtualizer< ++ getLogicalScrollOffset = () => this.getScrollOffset() + this.scrollAdjustments ++ + private getFurthestMeasurement = ( + measurements: Array, + index: number, diff --git a/patches/gcp-metadata@8.1.2.patch b/patches/gcp-metadata@8.1.2.patch new file mode 100644 index 0000000000..8b7667e29a --- /dev/null +++ b/patches/gcp-metadata@8.1.2.patch @@ -0,0 +1,14 @@ +diff --git a/build/src/index.js b/build/src/index.js +--- a/build/src/index.js ++++ b/build/src/index.js +@@ -323,6 +323,10 @@ async function isAvailable() { + if (process.env.DEBUG_AUTH) { + console.info(err); + } ++ // Promise.any() rejects with AggregateError when neither metadata host ++ // is available. This is expected outside GCP, not a warning condition. ++ if (err instanceof AggregateError) ++ return false; + if (err.type === 'request-timeout') { + // If running in a GCP environment, metadata endpoint should return + // within ms. diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index 44f24fb980..d93a2274e5 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -56,6 +56,10 @@ const testAllow: Record = { }, "kilocode/session/platform-attribution.test.ts": { count: 2, reason: "existing runtime integration test" }, "kilocode/session-prompt-queue.test.ts": { count: 6, reason: "prompt queue legacy instance bridge regression" }, + "kilocode/session-prompt-steering.test.ts": { + count: 2, + reason: "disk-backed prompt steering integration test cleanup", + }, "server/experimental-session-list.test.ts": { count: 2, reason: "Kilo session list integration test" }, "kilocode/server/cloud-session-import.test.ts": { count: 5, reason: "full app cloud import transaction integration" }, "kilocode/server/listener-runtime.test.ts": { count: 4, reason: "listener and AppRuntime integration test" }, diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index d6eae52ee2..4321693e01 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -551,10 +551,17 @@ async function main() { await git.stageAll() const compatMessage = `refactor: kilo compat for ${targetVersion.tag}` if (prior) { - const tree = await git.writeTree() + const transformed = await git.writeTree() + const tree = await git.overlayCompatTree({ + previous: prior.commit, + upstream: prior.upstream, + target: targetVersion.commit, + transformed, + extra: [".opencode-version"], + }) const commit = await git.createCommit(tree, compatMessage, prior.commit) await git.updateBranch(opencodeBranch, commit) - await git.checkout(opencodeBranch) + await $`git reset --hard ${commit}`.quiet() } else { await git.commit(compatMessage) } diff --git a/script/upstream/transforms/transform-package-json.test.ts b/script/upstream/transforms/transform-package-json.test.ts index 641969f501..2ad35eb5b7 100644 --- a/script/upstream/transforms/transform-package-json.test.ts +++ b/script/upstream/transforms/transform-package-json.test.ts @@ -4,6 +4,7 @@ import { fixCatalog, fixMetadata, fixPackageManager, + fixRepository, fixScripts, fixTrustedDependencies, mergeWithNewestVersions, @@ -32,6 +33,31 @@ test("fixScripts preserves Kilo-only root scripts from base", () => { expect(changes.some((c) => c.includes("dev-setup"))).toBe(true) }) +test("fixRepository preserves Kilo package links", () => { + const ours = { + repository: { url: "https://github.com/Kilo-Org/kilocode.git" }, + homepage: "https://github.com/Kilo-Org/kilocode/tree/main/packages/example", + bugs: "https://github.com/Kilo-Org/kilocode/issues", + } + const pkg: Record = { + repository: { url: "https://example.com/upstream.git" }, + homepage: "https://example.com/upstream/packages/example", + bugs: "https://example.com/upstream/issues", + } + const changes: string[] = [] + + fixRepository(pkg, ours, changes) + + expect(pkg.repository).toEqual(ours.repository) + expect(pkg.homepage).toBe(ours.homepage) + expect(pkg.bugs).toBe(ours.bugs) + expect(changes).toEqual([ + "repository: preserved Kilo metadata", + "homepage: preserved Kilo metadata", + "bugs: preserved Kilo metadata", + ]) +}) + test("fixScripts removes upstream-only dead scripts from root", () => { const pkg: Record = { scripts: { diff --git a/script/upstream/transforms/transform-package-json.ts b/script/upstream/transforms/transform-package-json.ts index 89d300e02b..87c26debcc 100644 --- a/script/upstream/transforms/transform-package-json.ts +++ b/script/upstream/transforms/transform-package-json.ts @@ -8,7 +8,7 @@ * 3. Injecting Kilo-specific dependencies * 4. Preserving Kilo's version number * 5. Preserving overrides and patchedDependencies - * 6. Preserving Kilo's repository configuration + * 6. Preserving Kilo's repository metadata * 7. Using "newest wins" strategy for dependency versions */ @@ -135,6 +135,19 @@ export function fixPackageManager( pkg.packageManager = next } +export function fixRepository( + pkg: Record, + ours: Record | null, + changes: string[], +): void { + if (!ours) return + for (const key of ["repository", "homepage", "bugs"] as const) { + if (ours[key] === undefined || JSON.stringify(pkg[key]) === JSON.stringify(ours[key])) continue + pkg[key] = ours[key] + changes.push(`${key}: preserved Kilo metadata`) + } +} + export function assertBunPackageManager(current: unknown, base: unknown, upstream: unknown): void { const inputs = [bun(base), bun(upstream)].filter((item): item is NonNullable => item !== null) if (inputs.length === 0) return @@ -549,12 +562,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt } } - // 6. Preserve repository (Kilo-specific, upstream doesn't have this) - const ourRepo = ourPkg.repository - if (ourRepo && JSON.stringify(pkg.repository) !== JSON.stringify(ourRepo)) { - pkg.repository = ourRepo - changes.push(`repository: preserved Kilo's repository configuration`) - } + // 6. Preserve repository metadata so published packages keep Kilo links + fixRepository(pkg, ourPkg, changes) fixMetadata(pkg, relativePath, ourPkg, changes) diff --git a/script/upstream/utils/git.test.ts b/script/upstream/utils/git.test.ts index b51dd35ea4..33b2a968db 100644 --- a/script/upstream/utils/git.test.ts +++ b/script/upstream/utils/git.test.ts @@ -9,6 +9,7 @@ import { getCommitHash, getCommitParents, isAncestor, + overlayCompatTree, recordAncestor, updateBranch, writeTree, @@ -108,3 +109,42 @@ test("finds previous compatibility commit when upstream tags diverge", async () expect(found?.upstream).toBe(side) expect(found?.commit).not.toBe(ancient) }) + +test("compatibility tree preserves Kilo paths unchanged upstream", async () => { + await Bun.write("shared.txt", "opencode A\n") + await Bun.write("unchanged.txt", "opencode unchanged\n") + await Bun.write("removed.txt", "remove me\n") + const old = await commit("release: v1.0.0") + + await Bun.write("shared.txt", "opencode B\n") + await Bun.write("added.txt", "opencode added\n") + await rm("removed.txt") + const target = await commit("release: v1.0.1") + + await $`git checkout -b main ${old}`.quiet() + await Bun.write("shared.txt", "kilo A\n") + await Bun.write("unchanged.txt", "kilo marker\n") + await Bun.write("kilo-only.txt", "keep me\n") + const previous = await commit("refactor: kilo compat for v1.0.0") + + await $`git checkout --detach ${target}`.quiet() + await Bun.write("shared.txt", "kilo B\n") + await Bun.write("added.txt", "kilo added\n") + await Bun.write(".opencode-version", "v1.0.1\n") + await $`git add -A`.quiet() + const transformed = await writeTree() + const tree = await overlayCompatTree({ + previous, + upstream: old, + target, + transformed, + extra: [".opencode-version"], + }) + + expect(await $`git show ${`${tree}:shared.txt`}`.text()).toBe("kilo B\n") + expect(await $`git show ${`${tree}:added.txt`}`.text()).toBe("kilo added\n") + expect(await $`git show ${`${tree}:unchanged.txt`}`.text()).toBe("kilo marker\n") + expect(await $`git show ${`${tree}:kilo-only.txt`}`.text()).toBe("keep me\n") + expect(await $`git show ${`${tree}:.opencode-version`}`.text()).toBe("v1.0.1\n") + expect((await $`git cat-file -e ${`${tree}:removed.txt`}`.quiet().nothrow()).exitCode).not.toBe(0) +}) diff --git a/script/upstream/utils/git.ts b/script/upstream/utils/git.ts index ab2c569341..da1fb4d52e 100644 --- a/script/upstream/utils/git.ts +++ b/script/upstream/utils/git.ts @@ -206,6 +206,48 @@ export async function writeTree(): Promise { return result.trim() } +function chunks(items: T[], size = 200): T[][] { + return Array.from({ length: Math.ceil(items.length / size) }, (_, idx) => items.slice(idx * size, (idx + 1) * size)) +} + +/** + * Overlay transformed upstream changes onto the previous compatibility tree. + * Paths unchanged upstream keep their prior Kilo content, including Kilo-only + * files and marker-bearing shared files. + */ +export async function overlayCompatTree(input: { + previous: string + upstream: string + target: string + transformed: string + extra?: string[] +}): Promise { + const diff = await $`git diff --name-only -z ${input.upstream} ${input.target}`.quiet().nothrow() + if (diff.exitCode !== 0) throw new Error(`Failed to list upstream changes: ${diff.stderr.toString()}`) + + const paths = Array.from( + new Set([ + ...diff.stdout + .toString() + .split("\0") + .filter((path) => path.length > 0), + ...(input.extra ?? []), + ]), + ) + const keep: string[] = [] + const remove: string[] = [] + for (const path of paths) { + const result = await $`git cat-file -e ${`${input.transformed}:${path}`}`.quiet().nothrow() + if (result.exitCode === 0) keep.push(path) + else remove.push(path) + } + + await $`git read-tree ${input.previous}`.quiet() + for (const batch of chunks(keep)) await $`git checkout ${input.transformed} -- ${batch}`.quiet() + for (const batch of chunks(remove)) await $`git update-index --force-remove -- ${batch}`.quiet() + return writeTree() +} + export async function createCommit(tree: string, message: string, parent: string): Promise { const result = await $`git commit-tree ${tree} -p ${parent} -m ${message}`.text() return result.trim()