From f4141a3c94c76f9cebc9b3a0110fdf540eed2ee7 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Fri, 13 Mar 2026 14:49:38 +0700 Subject: [PATCH 01/66] feat(cli): add roll-call command for batch-testing model connectivity Adds a new CLI command that allows users to test multiple models for connectivity and latency in parallel. This helps users discover which provider/model pairs are available and working. Features: - Filter models by regex pattern (e.g., 'kilo roll-call "free"') - Configurable prompt, timeout, and parallelization - JSON or table output formats - Verbose mode for real-time progress Closes Kilo-Org/kilocode#6304 --- packages/opencode/src/cli/cmd/roll-call.ts | 278 +++++++++++++++++++ packages/opencode/src/index.ts | 2 + packages/opencode/test/cli/roll-call.test.ts | 68 +++++ 3 files changed, 348 insertions(+) create mode 100644 packages/opencode/src/cli/cmd/roll-call.ts create mode 100644 packages/opencode/test/cli/roll-call.test.ts diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts new file mode 100644 index 00000000000..0aa7e19c947 --- /dev/null +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -0,0 +1,278 @@ +// kilocode_change - new file +import type { Argv } from "yargs" +import { Instance } from "../../project/instance" +import { Provider } from "../../provider/provider" +import { ProviderTransform } from "../../provider/transform" +import { cmd } from "./cmd" +import { UI } from "../ui" +import { generateText } from "ai" +import { randomUUID } from "crypto" + +const HEADERS = ["Model", "Access", "Snippet", "Latency"] +const SEPARATOR_PADDING = 9 + +const isTTY = process.stderr.isTTY ?? false + +function color(style: string): string { + return isTTY ? style : "" +} + +function sanitize(text: string): string { + return text + .replace(/\x1b\[[0-9;]*m/g, "") + .replace(/[\x00-\x1f\x7f]/g, "") +} + +function truncate(text: string, maxLen: number): string { + if (maxLen < 4) return text.substring(0, maxLen) + return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text +} + +export function formatTable( + rows: string[][], + terminalWidth: number, +): { header: string; separator: string; rows: string[] } { + const sanitizedRows = rows.map((row) => row.map((cell) => sanitize(cell ?? ""))) + + const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitizedRows.map((r) => r[i].length))) + + const totalWidth = widths.reduce((a, b) => a + b, 0) + SEPARATOR_PADDING + + const minSnippetWidth = HEADERS[2].length + 3 + if (totalWidth > terminalWidth && widths[2] > minSnippetWidth) { + const overflow = totalWidth - terminalWidth + widths[2] = Math.max(minSnippetWidth, widths[2] - overflow) + } + + const header = HEADERS.map((h, i) => h.padEnd(widths[i])).join(" | ") + const separator = "-".repeat(header.length) + + const formattedRows = sanitizedRows.map((row) => { + const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]] + return truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ") + }) + + return { header, separator, rows: formattedRows } +} + +export const RollCallCommand = cmd({ + command: "roll-call ", + describe: "batch-test models matching a filter for connectivity and latency", + builder: (yargs: Argv) => { + return yargs + .positional("filter", { + type: "string", + describe: "regex to filter models by provider/modelID (required)", + demandOption: true, + }) + .option("prompt", { + type: "string", + default: "Hello", + describe: "Prompt to send to each model", + }) + .option("timeout", { + type: "number", + default: 25000, + describe: "Timeout for each model call in milliseconds", + }) + .option("parallel", { + type: "number", + default: 5, + describe: "Number of parallel model calls", + }) + .option("verbose", { + type: "boolean", + default: false, + describe: "Show verbose output", + }) + .option("quiet", { + type: "boolean", + default: false, + describe: "Suppress non-error output", + }) + .option("output", { + type: "string", + choices: ["table", "json"], + default: "table", + describe: "Output format", + }) + }, + handler: async (args) => { + await rollCallHandler(args) + }, +}) + +interface RollCallResult { + model: string + access: boolean + snippet: string + latency: number | null + errorType: string | null + errorMessage: string | null +} + +export async function rollCallHandler(args: any) { + const { prompt, timeout, filter, parallel, output, verbose, quiet } = args + + if (!quiet) { + UI.println( + `${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`, + ) + UI.println( + `${color(UI.Style.TEXT_INFO)}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${color(UI.Style.TEXT_NORMAL)}`, + ) + } + + await Instance.provide({ + directory: process.cwd(), + async fn() { + const providers = await Provider.list() + const modelsToTest: { providerID: string; modelID: string; model: Provider.Model }[] = [] + + for (const [providerID, provider] of Object.entries(providers)) { + for (const [modelID, model] of Object.entries(provider.models)) { + const fullName = `${providerID}/${modelID}` + if (filter) { + try { + const regex = new RegExp(filter, "i") + if (!regex.test(fullName)) continue + } catch (e) { + UI.error(`Invalid filter regex: ${filter}`) + return + } + } + modelsToTest.push({ providerID, modelID, model }) + } + } + + if (modelsToTest.length === 0) { + if (!quiet) UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`) + return + } + + if (!quiet) { + UI.println(`${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`) + } + + const results: RollCallResult[] = [] + const queue = [...modelsToTest] + const activePromises: Promise[] = [] + + const processModel = async (item: (typeof modelsToTest)[0]) => { + const { providerID, modelID, model } = item + const fullName = `${providerID}/${modelID}` + const startTime = Date.now() + let access = false + let snippet = "" + let latency: number | null = null + let errorType: string | null = null + let errorMessage: string | null = null + + try { + const languageModel = await Provider.getLanguage(model) + const sessionID = randomUUID() + const baseOptions = ProviderTransform.options({ model, sessionID }) + const providerOptions = ProviderTransform.providerOptions(model, baseOptions) + const maxTokens = ProviderTransform.maxOutputTokens(model) + const temperature = ProviderTransform.temperature(model) + const topP = ProviderTransform.topP(model) + const topK = ProviderTransform.topK(model) + + const messages: Parameters[0]["messages"] = [{ role: "user", content: prompt }] + const transformedMessages = ProviderTransform.message(messages, model, baseOptions) + + const { text } = await generateText({ + model: languageModel, + messages: transformedMessages, + abortSignal: AbortSignal.timeout(timeout), + maxTokens, + temperature, + topP, + topK, + providerOptions, + }) + access = true + snippet = text.replace(/\n/g, " ") + latency = Date.now() - startTime + } catch (e: any) { + latency = Date.now() - startTime + if (e.name === "AbortError" || e.message?.includes("abort") || e.message?.includes("timeout")) { + errorType = "timeout" + errorMessage = "The operation timed out." + } else if (e.error?.type || e.error?.message) { + errorType = e.error?.type || "api_error" + errorMessage = e.error?.message || e.message + } else { + errorType = "unknown" + errorMessage = e.message || "An unknown error occurred" + } + } + + results.push({ + model: fullName, + access, + snippet, + latency, + errorType, + errorMessage, + }) + + if (verbose && !quiet) { + if (access) { + UI.println(`${color(UI.Style.TEXT_SUCCESS)}✔${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`) + } else { + UI.println( + `${color(UI.Style.TEXT_DANGER)}✘${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${errorType}: ${errorMessage}`, + ) + } + } + } + + while (queue.length > 0 || activePromises.length > 0) { + while (queue.length > 0 && activePromises.length < parallel) { + const item = queue.shift()! + const promise = processModel(item).finally(() => { + const index = activePromises.indexOf(promise) + if (index > -1) { + activePromises.splice(index, 1) + } + }) + activePromises.push(promise) + } + if (activePromises.length > 0) { + await Promise.race(activePromises) + } + } + + if (quiet) return + + if (output === "json") { + console.log(JSON.stringify(results, null, 2)) + } else { + const rows = results.map((r) => [ + r.model, + r.access ? "YES" : "NO", + r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "", + r.latency !== null ? `${r.latency}ms` : "N/A", + ]) + + const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80 + const table = formatTable(rows, terminalWidth) + + UI.println(table.header) + UI.println(table.separator) + table.rows.forEach((line, idx) => { + const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER + UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL)) + }) + + const successful = results.filter((r) => r.access).length + const failed = results.length - successful + UI.println("") + UI.println( + `${color(UI.Style.TEXT_SUCCESS)}${successful} accessible${color(UI.Style.TEXT_NORMAL)}, ${color(UI.Style.TEXT_DANGER)}${failed} failed${color(UI.Style.TEXT_NORMAL)}`, + ) + } + }, + }) +} \ No newline at end of file diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 688bc25b742..483612de377 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -8,6 +8,7 @@ import { AgentCommand } from "./cli/cmd/agent" import { UpgradeCommand } from "./cli/cmd/upgrade" import { UninstallCommand } from "./cli/cmd/uninstall" import { ModelsCommand } from "./cli/cmd/models" +import { RollCallCommand } from "./cli/cmd/roll-call" // kilocode_change import { UI } from "./cli/ui" import { Installation } from "./installation" import { NamedError } from "@opencode-ai/util/error" @@ -187,6 +188,7 @@ let cli = yargs(hideBin(process.argv)) .command(ServeCommand) // .command(WebCommand) // kilocode_change (Disabled unsupported opencode web UI) .command(ModelsCommand) + .command(RollCallCommand) // kilocode_change .command(StatsCommand) .command(ExportCommand) .command(ImportCommand) diff --git a/packages/opencode/test/cli/roll-call.test.ts b/packages/opencode/test/cli/roll-call.test.ts new file mode 100644 index 00000000000..2239f6c6f79 --- /dev/null +++ b/packages/opencode/test/cli/roll-call.test.ts @@ -0,0 +1,68 @@ +// kilocode_change - new file +import { test, expect, describe } from "bun:test" +import { formatTable } from "../../src/cli/cmd/roll-call" + +describe("formatTable", () => { + test("formats simple table correctly", () => { + const rows = [ + ["kilo/test-model", "YES", "Hello!", "1000ms"], + ["kilo/another-model", "NO", "(Error)", "500ms"], + ] + const result = formatTable(rows, 120) + + expect(result.header).toContain("Model") + expect(result.header).toContain("Access") + expect(result.header).toContain("Snippet") + expect(result.header).toContain("Latency") + expect(result.separator).toMatch(/^-+$/) + expect(result.rows).toHaveLength(2) + }) + + test("truncates long snippets", () => { + const longSnippet = "A".repeat(200) + const rows = [["model", "YES", longSnippet, "100ms"]] + const result = formatTable(rows, 80) + + const rowContent = result.rows[0] + const snippetStart = rowContent.indexOf("AAA") + expect(snippetStart).toBeGreaterThanOrEqual(0) + }) + + test("strips ANSI codes from cells", () => { + const rows = [["\x1b[31mmodel\x1b[0m", "YES", "text", "100ms"]] + const result = formatTable(rows, 120) + + expect(result.rows[0]).not.toContain("\x1b[") + expect(result.rows[0]).toContain("model") + }) + + test("handles empty rows", () => { + const result = formatTable([], 120) + expect(result.rows).toHaveLength(0) + expect(result.header).toContain("Model") + }) + + test("handles special characters in cells", () => { + const rows = [ + ["model\nwith\nnewlines", "YES", "text\ttab", "100ms"], + ["model\r\nwindows", "YES", "text", "100ms"], + ] + const result = formatTable(rows, 120) + + expect(result.rows[0]).not.toContain("\n") + expect(result.rows[0]).not.toContain("\t") + expect(result.rows[1]).not.toContain("\r") + }) + + test("adjusts column widths for terminal", () => { + const rows = [["very-long-model-name-here", "YES", "short", "100ms"]] + const wideResult = formatTable(rows, 200) + const narrowResult = formatTable(rows, 60) + + const wideSnippetStart = wideResult.header.indexOf("Snippet") + const narrowSnippetStart = narrowResult.header.indexOf("Snippet") + + expect(wideSnippetStart).toBeGreaterThanOrEqual(0) + expect(narrowSnippetStart).toBeGreaterThanOrEqual(0) + }) +}) \ No newline at end of file From f9eb217e7a2f0dc304c12ec1135c9e78af52dc82 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 10:06:12 +0700 Subject: [PATCH 02/66] fix(cli): validate --parallel and filter args in roll-call command Reject non-positive --parallel values that would cause the scheduler to spin forever, and reject empty filter strings that would bypass the safety guard and fan out to every configured model. --- packages/opencode/src/cli/cmd/roll-call.ts | 447 +++++++++++---------- 1 file changed, 228 insertions(+), 219 deletions(-) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index 0aa7e19c947..f6b0dbad6a4 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -14,265 +14,274 @@ const SEPARATOR_PADDING = 9 const isTTY = process.stderr.isTTY ?? false function color(style: string): string { - return isTTY ? style : "" + return isTTY ? style : "" } function sanitize(text: string): string { - return text - .replace(/\x1b\[[0-9;]*m/g, "") - .replace(/[\x00-\x1f\x7f]/g, "") + return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/[\x00-\x1f\x7f]/g, "") } function truncate(text: string, maxLen: number): string { - if (maxLen < 4) return text.substring(0, maxLen) - return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text + if (maxLen < 4) return text.substring(0, maxLen) + return text.length > maxLen ? text.substring(0, maxLen - 3) + "..." : text } export function formatTable( - rows: string[][], - terminalWidth: number, + rows: string[][], + terminalWidth: number, ): { header: string; separator: string; rows: string[] } { - const sanitizedRows = rows.map((row) => row.map((cell) => sanitize(cell ?? ""))) + const sanitizedRows = rows.map((row) => row.map((cell) => sanitize(cell ?? ""))) - const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitizedRows.map((r) => r[i].length))) + const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitizedRows.map((r) => r[i].length))) - const totalWidth = widths.reduce((a, b) => a + b, 0) + SEPARATOR_PADDING + const totalWidth = widths.reduce((a, b) => a + b, 0) + SEPARATOR_PADDING - const minSnippetWidth = HEADERS[2].length + 3 - if (totalWidth > terminalWidth && widths[2] > minSnippetWidth) { - const overflow = totalWidth - terminalWidth - widths[2] = Math.max(minSnippetWidth, widths[2] - overflow) - } + const minSnippetWidth = HEADERS[2].length + 3 + if (totalWidth > terminalWidth && widths[2] > minSnippetWidth) { + const overflow = totalWidth - terminalWidth + widths[2] = Math.max(minSnippetWidth, widths[2] - overflow) + } - const header = HEADERS.map((h, i) => h.padEnd(widths[i])).join(" | ") - const separator = "-".repeat(header.length) + const header = HEADERS.map((h, i) => h.padEnd(widths[i])).join(" | ") + const separator = "-".repeat(header.length) - const formattedRows = sanitizedRows.map((row) => { - const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]] - return truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ") - }) + const formattedRows = sanitizedRows.map((row) => { + const truncatedRow = [row[0], row[1], row[2] ? truncate(row[2], widths[2]) : row[2], row[3]] + return truncatedRow.map((c, i) => c.padEnd(widths[i])).join(" | ") + }) - return { header, separator, rows: formattedRows } + return { header, separator, rows: formattedRows } } export const RollCallCommand = cmd({ - command: "roll-call ", - describe: "batch-test models matching a filter for connectivity and latency", - builder: (yargs: Argv) => { - return yargs - .positional("filter", { - type: "string", - describe: "regex to filter models by provider/modelID (required)", - demandOption: true, - }) - .option("prompt", { - type: "string", - default: "Hello", - describe: "Prompt to send to each model", - }) - .option("timeout", { - type: "number", - default: 25000, - describe: "Timeout for each model call in milliseconds", - }) - .option("parallel", { - type: "number", - default: 5, - describe: "Number of parallel model calls", - }) - .option("verbose", { - type: "boolean", - default: false, - describe: "Show verbose output", - }) - .option("quiet", { - type: "boolean", - default: false, - describe: "Suppress non-error output", - }) - .option("output", { - type: "string", - choices: ["table", "json"], - default: "table", - describe: "Output format", - }) - }, - handler: async (args) => { - await rollCallHandler(args) - }, + command: "roll-call ", + describe: "batch-test models matching a filter for connectivity and latency", + builder: (yargs: Argv) => { + return yargs + .positional("filter", { + type: "string", + describe: "regex to filter models by provider/modelID (required)", + demandOption: true, + }) + .option("prompt", { + type: "string", + default: "Hello", + describe: "Prompt to send to each model", + }) + .option("timeout", { + type: "number", + default: 25000, + describe: "Timeout for each model call in milliseconds", + }) + .option("parallel", { + type: "number", + default: 5, + describe: "Number of parallel model calls", + }) + .option("verbose", { + type: "boolean", + default: false, + describe: "Show verbose output", + }) + .option("quiet", { + type: "boolean", + default: false, + describe: "Suppress non-error output", + }) + .option("output", { + type: "string", + choices: ["table", "json"], + default: "table", + describe: "Output format", + }) + }, + handler: async (args) => { + await rollCallHandler(args) + }, }) interface RollCallResult { - model: string - access: boolean - snippet: string - latency: number | null - errorType: string | null - errorMessage: string | null + model: string + access: boolean + snippet: string + latency: number | null + errorType: string | null + errorMessage: string | null } export async function rollCallHandler(args: any) { - const { prompt, timeout, filter, parallel, output, verbose, quiet } = args + const { prompt, timeout, filter, parallel, output, verbose, quiet } = args - if (!quiet) { - UI.println( - `${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`, - ) - UI.println( - `${color(UI.Style.TEXT_INFO)}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${color(UI.Style.TEXT_NORMAL)}`, - ) - } + if (parallel < 1) { + UI.error("--parallel must be at least 1") + return + } - await Instance.provide({ - directory: process.cwd(), - async fn() { - const providers = await Provider.list() - const modelsToTest: { providerID: string; modelID: string; model: Provider.Model }[] = [] + if (!filter || !filter.trim()) { + UI.error("filter is required and cannot be empty") + return + } - for (const [providerID, provider] of Object.entries(providers)) { - for (const [modelID, model] of Object.entries(provider.models)) { - const fullName = `${providerID}/${modelID}` - if (filter) { - try { - const regex = new RegExp(filter, "i") - if (!regex.test(fullName)) continue - } catch (e) { - UI.error(`Invalid filter regex: ${filter}`) - return - } - } - modelsToTest.push({ providerID, modelID, model }) - } - } + if (!quiet) { + UI.println( + `${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`, + ) + UI.println( + `${color(UI.Style.TEXT_INFO)}Timeout per model: ${timeout}ms, Parallel calls: ${parallel}${color(UI.Style.TEXT_NORMAL)}`, + ) + } - if (modelsToTest.length === 0) { - if (!quiet) UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`) - return - } + await Instance.provide({ + directory: process.cwd(), + async fn() { + const providers = await Provider.list() + const modelsToTest: { providerID: string; modelID: string; model: Provider.Model }[] = [] - if (!quiet) { - UI.println(`${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`) - } + for (const [providerID, provider] of Object.entries(providers)) { + for (const [modelID, model] of Object.entries(provider.models)) { + const fullName = `${providerID}/${modelID}` + try { + const regex = new RegExp(filter, "i") + if (!regex.test(fullName)) continue + } catch (e) { + UI.error(`Invalid filter regex: ${filter}`) + return + } + modelsToTest.push({ providerID, modelID, model }) + } + } - const results: RollCallResult[] = [] - const queue = [...modelsToTest] - const activePromises: Promise[] = [] + if (modelsToTest.length === 0) { + if (!quiet) + UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`) + return + } - const processModel = async (item: (typeof modelsToTest)[0]) => { - const { providerID, modelID, model } = item - const fullName = `${providerID}/${modelID}` - const startTime = Date.now() - let access = false - let snippet = "" - let latency: number | null = null - let errorType: string | null = null - let errorMessage: string | null = null + if (!quiet) { + UI.println( + `${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`, + ) + } - try { - const languageModel = await Provider.getLanguage(model) - const sessionID = randomUUID() - const baseOptions = ProviderTransform.options({ model, sessionID }) - const providerOptions = ProviderTransform.providerOptions(model, baseOptions) - const maxTokens = ProviderTransform.maxOutputTokens(model) - const temperature = ProviderTransform.temperature(model) - const topP = ProviderTransform.topP(model) - const topK = ProviderTransform.topK(model) + const results: RollCallResult[] = [] + const queue = [...modelsToTest] + const activePromises: Promise[] = [] - const messages: Parameters[0]["messages"] = [{ role: "user", content: prompt }] - const transformedMessages = ProviderTransform.message(messages, model, baseOptions) + const processModel = async (item: (typeof modelsToTest)[0]) => { + const { providerID, modelID, model } = item + const fullName = `${providerID}/${modelID}` + const startTime = Date.now() + let access = false + let snippet = "" + let latency: number | null = null + let errorType: string | null = null + let errorMessage: string | null = null - const { text } = await generateText({ - model: languageModel, - messages: transformedMessages, - abortSignal: AbortSignal.timeout(timeout), - maxTokens, - temperature, - topP, - topK, - providerOptions, - }) - access = true - snippet = text.replace(/\n/g, " ") - latency = Date.now() - startTime - } catch (e: any) { - latency = Date.now() - startTime - if (e.name === "AbortError" || e.message?.includes("abort") || e.message?.includes("timeout")) { - errorType = "timeout" - errorMessage = "The operation timed out." - } else if (e.error?.type || e.error?.message) { - errorType = e.error?.type || "api_error" - errorMessage = e.error?.message || e.message - } else { - errorType = "unknown" - errorMessage = e.message || "An unknown error occurred" - } - } + try { + const languageModel = await Provider.getLanguage(model) + const sessionID = randomUUID() + const baseOptions = ProviderTransform.options({ model, sessionID }) + const providerOptions = ProviderTransform.providerOptions(model, baseOptions) + const maxOutputTokens = ProviderTransform.maxOutputTokens(model) + const temperature = ProviderTransform.temperature(model) + const topP = ProviderTransform.topP(model) + const topK = ProviderTransform.topK(model) - results.push({ - model: fullName, - access, - snippet, - latency, - errorType, - errorMessage, - }) + const messages: Parameters[0]["messages"] = [{ role: "user", content: prompt }] + const transformedMessages = ProviderTransform.message(messages, model, baseOptions) - if (verbose && !quiet) { - if (access) { - UI.println(`${color(UI.Style.TEXT_SUCCESS)}✔${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`) - } else { - UI.println( - `${color(UI.Style.TEXT_DANGER)}✘${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${errorType}: ${errorMessage}`, - ) - } - } - } + const { text } = await generateText({ + model: languageModel, + messages: transformedMessages, + abortSignal: AbortSignal.timeout(timeout), + maxOutputTokens, + temperature, + topP, + topK, + providerOptions, + }) + access = true + snippet = text.replace(/\n/g, " ") + latency = Date.now() - startTime + } catch (e: any) { + latency = Date.now() - startTime + if (e.name === "AbortError" || e.message?.includes("abort") || e.message?.includes("timeout")) { + errorType = "timeout" + errorMessage = "The operation timed out." + } else if (e.error?.type || e.error?.message) { + errorType = e.error?.type || "api_error" + errorMessage = e.error?.message || e.message + } else { + errorType = "unknown" + errorMessage = e.message || "An unknown error occurred" + } + } - while (queue.length > 0 || activePromises.length > 0) { - while (queue.length > 0 && activePromises.length < parallel) { - const item = queue.shift()! - const promise = processModel(item).finally(() => { - const index = activePromises.indexOf(promise) - if (index > -1) { - activePromises.splice(index, 1) - } - }) - activePromises.push(promise) - } - if (activePromises.length > 0) { - await Promise.race(activePromises) - } - } + results.push({ + model: fullName, + access, + snippet, + latency, + errorType, + errorMessage, + }) - if (quiet) return + if (verbose && !quiet) { + if (access) { + UI.println(`${color(UI.Style.TEXT_SUCCESS)}✔${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`) + } else { + UI.println( + `${color(UI.Style.TEXT_DANGER)}✘${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${errorType}: ${errorMessage}`, + ) + } + } + } - if (output === "json") { - console.log(JSON.stringify(results, null, 2)) - } else { - const rows = results.map((r) => [ - r.model, - r.access ? "YES" : "NO", - r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "", - r.latency !== null ? `${r.latency}ms` : "N/A", - ]) + while (queue.length > 0 || activePromises.length > 0) { + while (queue.length > 0 && activePromises.length < parallel) { + const item = queue.shift()! + const promise = processModel(item).finally(() => { + const index = activePromises.indexOf(promise) + if (index > -1) { + activePromises.splice(index, 1) + } + }) + activePromises.push(promise) + } + if (activePromises.length > 0) { + await Promise.race(activePromises) + } + } - const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80 - const table = formatTable(rows, terminalWidth) + if (quiet) return - UI.println(table.header) - UI.println(table.separator) - table.rows.forEach((line, idx) => { - const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER - UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL)) - }) + if (output === "json") { + console.log(JSON.stringify(results, null, 2)) + } else { + const rows = results.map((r) => [ + r.model, + r.access ? "YES" : "NO", + r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "", + r.latency !== null ? `${r.latency}ms` : "N/A", + ]) - const successful = results.filter((r) => r.access).length - const failed = results.length - successful - UI.println("") - UI.println( - `${color(UI.Style.TEXT_SUCCESS)}${successful} accessible${color(UI.Style.TEXT_NORMAL)}, ${color(UI.Style.TEXT_DANGER)}${failed} failed${color(UI.Style.TEXT_NORMAL)}`, - ) - } - }, - }) -} \ No newline at end of file + const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80 + const table = formatTable(rows, terminalWidth) + + UI.println(table.header) + UI.println(table.separator) + table.rows.forEach((line, idx) => { + const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER + UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL)) + }) + + const successful = results.filter((r) => r.access).length + const failed = results.length - successful + UI.println("") + UI.println( + `${color(UI.Style.TEXT_SUCCESS)}${successful} accessible${color(UI.Style.TEXT_NORMAL)}, ${color(UI.Style.TEXT_DANGER)}${failed} failed${color(UI.Style.TEXT_NORMAL)}`, + ) + } + }, + }) +} From 5be559ff4de313df187e44b316fc9776653f5ed0 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 10:23:25 +0700 Subject: [PATCH 03/66] fix(cli): skip non-text models in roll-call Filter out models without text output capability (e.g. embeddings, image-only) since generateText is the wrong probe for them and would produce false negatives. --- packages/opencode/src/cli/cmd/roll-call.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index f6b0dbad6a4..898efd340a3 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -147,6 +147,7 @@ export async function rollCallHandler(args: any) { UI.error(`Invalid filter regex: ${filter}`) return } + if (!model.capabilities.output.text) continue modelsToTest.push({ providerID, modelID, model }) } } From 658355ee74d2b7471e2f57133506c6cc0bf32254 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 10:24:51 +0700 Subject: [PATCH 04/66] fix(cli): clarify roll-call tests text models only --- packages/opencode/src/cli/cmd/roll-call.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index 898efd340a3..e44ed09d7d6 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -55,7 +55,7 @@ export function formatTable( export const RollCallCommand = cmd({ command: "roll-call ", - describe: "batch-test models matching a filter for connectivity and latency", + describe: "batch-test text models matching a filter for connectivity and latency", builder: (yargs: Argv) => { return yargs .positional("filter", { From 6b963434572cd6e431f31ca4f0ea1ee5a2799f14 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 10:39:43 +0700 Subject: [PATCH 05/66] fix(cli): exit non-zero when roll-call fails to test any models --- packages/opencode/src/cli/cmd/roll-call.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index e44ed09d7d6..30aeb431053 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -114,11 +114,13 @@ export async function rollCallHandler(args: any) { if (parallel < 1) { UI.error("--parallel must be at least 1") + process.exitCode = 1 return } if (!filter || !filter.trim()) { UI.error("filter is required and cannot be empty") + process.exitCode = 1 return } @@ -155,6 +157,7 @@ export async function rollCallHandler(args: any) { if (modelsToTest.length === 0) { if (!quiet) UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`) + process.exitCode = 1 return } From 81cc9f95794d6a1bf7a130955ca51da2f6e56fbe Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 10:47:22 +0700 Subject: [PATCH 06/66] fix(cli): also require text input capability in roll-call model filter Extract isTextModel predicate and add tests covering multimodal, audio-only, image-only, and embedding models. --- packages/opencode/src/cli/cmd/roll-call.ts | 6 +- packages/opencode/test/cli/roll-call.test.ts | 143 ++++++++++++------- 2 files changed, 95 insertions(+), 54 deletions(-) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index 30aeb431053..cc71631a5b2 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -53,6 +53,10 @@ export function formatTable( return { header, separator, rows: formattedRows } } +export function isTextModel(model: Provider.Model): boolean { + return model.capabilities.input.text && model.capabilities.output.text +} + export const RollCallCommand = cmd({ command: "roll-call ", describe: "batch-test text models matching a filter for connectivity and latency", @@ -149,7 +153,7 @@ export async function rollCallHandler(args: any) { UI.error(`Invalid filter regex: ${filter}`) return } - if (!model.capabilities.output.text) continue + if (!isTextModel(model)) continue modelsToTest.push({ providerID, modelID, model }) } } diff --git a/packages/opencode/test/cli/roll-call.test.ts b/packages/opencode/test/cli/roll-call.test.ts index 2239f6c6f79..b8cb996f8cb 100644 --- a/packages/opencode/test/cli/roll-call.test.ts +++ b/packages/opencode/test/cli/roll-call.test.ts @@ -1,68 +1,105 @@ // kilocode_change - new file import { test, expect, describe } from "bun:test" -import { formatTable } from "../../src/cli/cmd/roll-call" +import { formatTable, isTextModel } from "../../src/cli/cmd/roll-call" describe("formatTable", () => { - test("formats simple table correctly", () => { - const rows = [ - ["kilo/test-model", "YES", "Hello!", "1000ms"], - ["kilo/another-model", "NO", "(Error)", "500ms"], - ] - const result = formatTable(rows, 120) + test("formats simple table correctly", () => { + const rows = [ + ["kilo/test-model", "YES", "Hello!", "1000ms"], + ["kilo/another-model", "NO", "(Error)", "500ms"], + ] + const result = formatTable(rows, 120) - expect(result.header).toContain("Model") - expect(result.header).toContain("Access") - expect(result.header).toContain("Snippet") - expect(result.header).toContain("Latency") - expect(result.separator).toMatch(/^-+$/) - expect(result.rows).toHaveLength(2) - }) + expect(result.header).toContain("Model") + expect(result.header).toContain("Access") + expect(result.header).toContain("Snippet") + expect(result.header).toContain("Latency") + expect(result.separator).toMatch(/^-+$/) + expect(result.rows).toHaveLength(2) + }) - test("truncates long snippets", () => { - const longSnippet = "A".repeat(200) - const rows = [["model", "YES", longSnippet, "100ms"]] - const result = formatTable(rows, 80) + test("truncates long snippets", () => { + const longSnippet = "A".repeat(200) + const rows = [["model", "YES", longSnippet, "100ms"]] + const result = formatTable(rows, 80) - const rowContent = result.rows[0] - const snippetStart = rowContent.indexOf("AAA") - expect(snippetStart).toBeGreaterThanOrEqual(0) - }) + const rowContent = result.rows[0] + const snippetStart = rowContent.indexOf("AAA") + expect(snippetStart).toBeGreaterThanOrEqual(0) + }) - test("strips ANSI codes from cells", () => { - const rows = [["\x1b[31mmodel\x1b[0m", "YES", "text", "100ms"]] - const result = formatTable(rows, 120) + test("strips ANSI codes from cells", () => { + const rows = [["\x1b[31mmodel\x1b[0m", "YES", "text", "100ms"]] + const result = formatTable(rows, 120) - expect(result.rows[0]).not.toContain("\x1b[") - expect(result.rows[0]).toContain("model") - }) + expect(result.rows[0]).not.toContain("\x1b[") + expect(result.rows[0]).toContain("model") + }) - test("handles empty rows", () => { - const result = formatTable([], 120) - expect(result.rows).toHaveLength(0) - expect(result.header).toContain("Model") - }) + test("handles empty rows", () => { + const result = formatTable([], 120) + expect(result.rows).toHaveLength(0) + expect(result.header).toContain("Model") + }) - test("handles special characters in cells", () => { - const rows = [ - ["model\nwith\nnewlines", "YES", "text\ttab", "100ms"], - ["model\r\nwindows", "YES", "text", "100ms"], - ] - const result = formatTable(rows, 120) + test("handles special characters in cells", () => { + const rows = [ + ["model\nwith\nnewlines", "YES", "text\ttab", "100ms"], + ["model\r\nwindows", "YES", "text", "100ms"], + ] + const result = formatTable(rows, 120) - expect(result.rows[0]).not.toContain("\n") - expect(result.rows[0]).not.toContain("\t") - expect(result.rows[1]).not.toContain("\r") - }) + expect(result.rows[0]).not.toContain("\n") + expect(result.rows[0]).not.toContain("\t") + expect(result.rows[1]).not.toContain("\r") + }) - test("adjusts column widths for terminal", () => { - const rows = [["very-long-model-name-here", "YES", "short", "100ms"]] - const wideResult = formatTable(rows, 200) - const narrowResult = formatTable(rows, 60) + test("adjusts column widths for terminal", () => { + const rows = [["very-long-model-name-here", "YES", "short", "100ms"]] + const wideResult = formatTable(rows, 200) + const narrowResult = formatTable(rows, 60) - const wideSnippetStart = wideResult.header.indexOf("Snippet") - const narrowSnippetStart = narrowResult.header.indexOf("Snippet") + const wideSnippetStart = wideResult.header.indexOf("Snippet") + const narrowSnippetStart = narrowResult.header.indexOf("Snippet") - expect(wideSnippetStart).toBeGreaterThanOrEqual(0) - expect(narrowSnippetStart).toBeGreaterThanOrEqual(0) - }) -}) \ No newline at end of file + expect(wideSnippetStart).toBeGreaterThanOrEqual(0) + expect(narrowSnippetStart).toBeGreaterThanOrEqual(0) + }) +}) + +describe("isTextModel", () => { + const base = { + input: { text: false, audio: false, image: false, video: false, pdf: false }, + output: { text: false, audio: false, image: false, video: false, pdf: false }, + } + + function caps(overrides: { input?: Partial; output?: Partial }) { + return { + capabilities: { + ...base, + input: { ...base.input, ...overrides.input }, + output: { ...base.output, ...overrides.output }, + }, + } as any + } + + test("accepts text-in text-out model", () => { + expect(isTextModel(caps({ input: { text: true }, output: { text: true } }))).toBe(true) + }) + + test("accepts multimodal model with text capability", () => { + expect(isTextModel(caps({ input: { text: true, image: true }, output: { text: true } }))).toBe(true) + }) + + test("rejects audio-in text-out model (e.g. whisper)", () => { + expect(isTextModel(caps({ input: { audio: true }, output: { text: true } }))).toBe(false) + }) + + test("rejects text-in image-out model (e.g. dall-e)", () => { + expect(isTextModel(caps({ input: { text: true }, output: { image: true } }))).toBe(false) + }) + + test("rejects embedding model (no text output)", () => { + expect(isTextModel(caps({ input: { text: true } }))).toBe(false) + }) +}) From aa0f60f0b484181c4e39244395199785de4f66e2 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 11:11:53 +0700 Subject: [PATCH 07/66] feat(cli): add markdown output mode and fix quiet/json interaction JSON and md modes emit clean data to stdout with no decoration. --quiet now only suppresses progress, never the result output. Add formatMarkdown and tests. --- packages/opencode/src/cli/cmd/roll-call.ts | 69 +++++++++++++------- packages/opencode/test/cli/roll-call.test.ts | 23 ++++++- 2 files changed, 66 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index cc71631a5b2..a771985ce16 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -53,6 +53,16 @@ export function formatTable( return { header, separator, rows: formattedRows } } +export function formatMarkdown(rows: string[][]): string { + const sanitized = rows.map((row) => row.map((cell) => sanitize(cell ?? ""))) + const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitized.map((r) => r[i].length))) + const pad = (text: string, i: number) => text.padEnd(widths[i]) + const header = "| " + HEADERS.map((h, i) => pad(h, i)).join(" | ") + " |" + const separator = "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |" + const body = sanitized.map((row) => "| " + row.map((c, i) => pad(c, i)).join(" | ") + " |") + return [header, separator, ...body].join("\n") +} + export function isTextModel(model: Provider.Model): boolean { return model.capabilities.input.text && model.capabilities.output.text } @@ -90,13 +100,13 @@ export const RollCallCommand = cmd({ .option("quiet", { type: "boolean", default: false, - describe: "Suppress non-error output", + describe: "Suppress progress and decoration", }) .option("output", { type: "string", - choices: ["table", "json"], + choices: ["table", "json", "md"], default: "table", - describe: "Output format", + describe: "Output format (table, json, or md)", }) }, handler: async (args) => { @@ -128,7 +138,9 @@ export async function rollCallHandler(args: any) { return } - if (!quiet) { + const json = output === "json" + + if (!quiet && !json) { UI.println( `${color(UI.Style.TEXT_INFO)}Starting roll call for models with prompt: "${prompt}"${color(UI.Style.TEXT_NORMAL)}`, ) @@ -159,13 +171,14 @@ export async function rollCallHandler(args: any) { } if (modelsToTest.length === 0) { - if (!quiet) + if (!quiet && !json) UI.println(`${color(UI.Style.TEXT_WARNING)}No models to test after filtering.${color(UI.Style.TEXT_NORMAL)}`) + if (json) console.log(JSON.stringify([], null, 2)) process.exitCode = 1 return } - if (!quiet) { + if (!quiet && !json) { UI.println( `${color(UI.Style.TEXT_INFO)}Prompting ${modelsToTest.length} models...${color(UI.Style.TEXT_NORMAL)}`, ) @@ -234,7 +247,7 @@ export async function rollCallHandler(args: any) { errorMessage, }) - if (verbose && !quiet) { + if (verbose && !quiet && !json) { if (access) { UI.println(`${color(UI.Style.TEXT_SUCCESS)}✔${color(UI.Style.TEXT_NORMAL)} ${fullName} - ${latency}ms`) } else { @@ -261,28 +274,34 @@ export async function rollCallHandler(args: any) { } } - if (quiet) return - - if (output === "json") { + if (json) { console.log(JSON.stringify(results, null, 2)) - } else { - const rows = results.map((r) => [ - r.model, - r.access ? "YES" : "NO", - r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "", - r.latency !== null ? `${r.latency}ms` : "N/A", - ]) + return + } - const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80 - const table = formatTable(rows, terminalWidth) + const rows = results.map((r) => [ + r.model, + r.access ? "YES" : "NO", + r.access ? r.snippet : r.errorMessage ? `(${r.errorMessage})` : "", + r.latency !== null ? `${r.latency}ms` : "N/A", + ]) - UI.println(table.header) - UI.println(table.separator) - table.rows.forEach((line, idx) => { - const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER - UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL)) - }) + if (output === "md") { + console.log(formatMarkdown(rows)) + return + } + const terminalWidth = parseInt(process.env.COLUMNS || "", 10) || process.stdout.columns || 80 + const table = formatTable(rows, terminalWidth) + + UI.println(table.header) + UI.println(table.separator) + table.rows.forEach((line, idx) => { + const rowColor = results[idx].access ? UI.Style.TEXT_SUCCESS : UI.Style.TEXT_DANGER + UI.println(color(rowColor) + line + color(UI.Style.TEXT_NORMAL)) + }) + + if (!quiet) { const successful = results.filter((r) => r.access).length const failed = results.length - successful UI.println("") diff --git a/packages/opencode/test/cli/roll-call.test.ts b/packages/opencode/test/cli/roll-call.test.ts index b8cb996f8cb..f5c63e5f3a3 100644 --- a/packages/opencode/test/cli/roll-call.test.ts +++ b/packages/opencode/test/cli/roll-call.test.ts @@ -1,6 +1,6 @@ // kilocode_change - new file import { test, expect, describe } from "bun:test" -import { formatTable, isTextModel } from "../../src/cli/cmd/roll-call" +import { formatTable, formatMarkdown, isTextModel } from "../../src/cli/cmd/roll-call" describe("formatTable", () => { test("formats simple table correctly", () => { @@ -103,3 +103,24 @@ describe("isTextModel", () => { expect(isTextModel(caps({ input: { text: true } }))).toBe(false) }) }) + +describe("formatMarkdown", () => { + test("produces valid markdown table", () => { + const rows = [ + ["openai/gpt-4o", "YES", "Hello!", "500ms"], + ["openai/gpt-3.5", "NO", "(timeout)", "25000ms"], + ] + const md = formatMarkdown(rows) + const lines = md.split("\n") + + expect(lines[0]).toMatch(/^\|.*Model.*\|.*Access.*\|.*Snippet.*\|.*Latency.*\|$/) + expect(lines[1]).toMatch(/^\| -+ \| -+ \| -+ \| -+ \|$/) + expect(lines).toHaveLength(4) + }) + + test("handles empty rows", () => { + const md = formatMarkdown([]) + const lines = md.split("\n") + expect(lines).toHaveLength(2) // header + separator only + }) +}) From 1536addc5bdc79ea6e9267d69a95eb53f3a09ef0 Mon Sep 17 00:00:00 2001 From: Conny Brunnkvist Date: Wed, 18 Mar 2026 11:27:02 +0700 Subject: [PATCH 08/66] fix(cli): escape pipes in markdown output and exit non-zero on bad regex Escape | in markdown table cells to prevent broken rows. Add process.exitCode = 1 on the invalid regex path. --- packages/opencode/src/cli/cmd/roll-call.ts | 7 ++++--- packages/opencode/test/cli/roll-call.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index a771985ce16..caf6a09eabc 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -54,12 +54,12 @@ export function formatTable( } export function formatMarkdown(rows: string[][]): string { - const sanitized = rows.map((row) => row.map((cell) => sanitize(cell ?? ""))) - const widths = HEADERS.map((h, i) => Math.max(h.length, ...sanitized.map((r) => r[i].length))) + const escaped = rows.map((row) => row.map((cell) => sanitize(cell ?? "").replace(/\|/g, "\\|"))) + const widths = HEADERS.map((h, i) => Math.max(h.length, ...escaped.map((r) => r[i].length))) const pad = (text: string, i: number) => text.padEnd(widths[i]) const header = "| " + HEADERS.map((h, i) => pad(h, i)).join(" | ") + " |" const separator = "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |" - const body = sanitized.map((row) => "| " + row.map((c, i) => pad(c, i)).join(" | ") + " |") + const body = escaped.map((row) => "| " + row.map((c, i) => pad(c, i)).join(" | ") + " |") return [header, separator, ...body].join("\n") } @@ -163,6 +163,7 @@ export async function rollCallHandler(args: any) { if (!regex.test(fullName)) continue } catch (e) { UI.error(`Invalid filter regex: ${filter}`) + process.exitCode = 1 return } if (!isTextModel(model)) continue diff --git a/packages/opencode/test/cli/roll-call.test.ts b/packages/opencode/test/cli/roll-call.test.ts index f5c63e5f3a3..b22c004acc8 100644 --- a/packages/opencode/test/cli/roll-call.test.ts +++ b/packages/opencode/test/cli/roll-call.test.ts @@ -123,4 +123,11 @@ describe("formatMarkdown", () => { const lines = md.split("\n") expect(lines).toHaveLength(2) // header + separator only }) + + test("escapes pipe characters in cells", () => { + const rows = [["model", "YES", "hello | world", "100ms"]] + const md = formatMarkdown(rows) + expect(md).toContain("hello \\| world") + expect(md.split("\n")[2].match(/(? Date: Wed, 18 Mar 2026 11:36:52 +0700 Subject: [PATCH 09/66] fix(cli): reject non-positive --timeout in roll-call --- packages/opencode/src/cli/cmd/roll-call.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/opencode/src/cli/cmd/roll-call.ts b/packages/opencode/src/cli/cmd/roll-call.ts index caf6a09eabc..dde01e2a4b0 100644 --- a/packages/opencode/src/cli/cmd/roll-call.ts +++ b/packages/opencode/src/cli/cmd/roll-call.ts @@ -132,6 +132,12 @@ export async function rollCallHandler(args: any) { return } + if (timeout < 1) { + UI.error("--timeout must be at least 1") + process.exitCode = 1 + return + } + if (!filter || !filter.trim()) { UI.error("filter is required and cannot be empty") process.exitCode = 1 From e498c02f7acc5c228bbd45f9e4f294bf5def21ca Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 10 Apr 2026 17:13:19 -0400 Subject: [PATCH 10/66] ux(cli): visually show when applied patch skips sections --- .changeset/fix-tui-diff-hunk-headers.md | 6 + .../src/cli/cmd/tui/routes/session/index.tsx | 110 +++++++++++------- .../cli/cmd/tui/routes/session/permission.tsx | 53 ++++++--- packages/opencode/src/kilocode/tui/diff.ts | 79 +++++++++++++ .../opencode/test/kilocode/tui-diff.test.ts | 91 +++++++++++++++ 5 files changed, 280 insertions(+), 59 deletions(-) create mode 100644 .changeset/fix-tui-diff-hunk-headers.md create mode 100644 packages/opencode/src/kilocode/tui/diff.ts create mode 100644 packages/opencode/test/kilocode/tui-diff.test.ts diff --git a/.changeset/fix-tui-diff-hunk-headers.md b/.changeset/fix-tui-diff-hunk-headers.md new file mode 100644 index 00000000000..2406a74b89c --- /dev/null +++ b/.changeset/fix-tui-diff-hunk-headers.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix TUI diff rendering when header-like content lines appear inside a unified diff hunk. diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7528f811fae..598fbe1629f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -83,6 +83,8 @@ import * as Model from "../../util/model" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change + import { formatMarkdownTables } from "../../util/markdown" // kilocode_change import { bell } from "@/kilocode/bell" // kilocode_change import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change @@ -2221,32 +2223,46 @@ function Edit(props: ToolProps) { const ft = createMemo(() => filetype(props.input.filePath)) const diffContent = createMemo(() => props.metadata.diff) + const hunks = createMemo(() => splitDiffHunks(diffContent() ?? "")) // kilocode_change return ( - - + {/* kilocode_change start */} + + + {(hunk, i) => ( + <> + 0}> + + ... + + + + + )} + + {/* kilocode_change end */} @@ -2272,29 +2288,43 @@ function ApplyPatch(props: ToolProps) { }) function Diff(p: { diff: string; filePath: string }) { + // kilocode_change start + const hunks = createMemo(() => splitDiffHunks(p.diff)) return ( - - + + + {(hunk, i) => ( + <> + 0}> + + ... + + + + + )} + ) + // kilocode_change end } function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index b461256a4e5..90bba877c3e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -19,6 +19,7 @@ import { useDialog } from "../../ui/dialog" import { getScrollAcceleration } from "../../util/scroll" import { useTuiConfig } from "../../context/tui-config" import { ConfigProtection } from "@/kilocode/permission/config-paths" // kilocode_change +import { splitDiffHunks } from "@/kilocode/tui/diff" // kilocode_change type PermissionStage = "permission" | "always" | "reject" @@ -66,6 +67,7 @@ function EditBody(props: { request: PermissionRequest }) { const ft = createMemo(() => filetype(filepath())) const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) + const hunks = createMemo(() => splitDiffHunks(diff())) // kilocode_change return ( @@ -80,25 +82,38 @@ function EditBody(props: { request: PermissionRequest }) { }, }} > - + {/* kilocode_change start */} + + + {(hunk, i) => ( + <> + 0}> + ... + + + + )} + + + {/* kilocode_change end */} diff --git a/packages/opencode/src/kilocode/tui/diff.ts b/packages/opencode/src/kilocode/tui/diff.ts new file mode 100644 index 00000000000..65e9b67f701 --- /dev/null +++ b/packages/opencode/src/kilocode/tui/diff.ts @@ -0,0 +1,79 @@ +export function splitDiffHunks(diff: string): string[] { + const parse = (line: string) => { + const match = line.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/) + if (!match) return + return { + old: Number(match[1] ?? "1"), + next: Number(match[2] ?? "1"), + } + } + + const split = (section: string[]) => { + const start = section.findIndex((line) => line.startsWith("@@")) + if (start === -1) return [section.join("\n")] + + const prefix = section.slice(0, start) + const hunks = section.slice(start).reduce((acc, line) => { + if (line.startsWith("@@")) return [...acc, [line]] + if (acc.length === 0) return [[line]] + return [...acc.slice(0, -1), [...acc.at(-1)!, line]] + }, [] as string[][]) + + const head = prefix.join("\n") + return hunks.map((hunk) => [head, ...hunk].join("\n")) + } + + const lines = diff.split("\n") + const files = lines.reduce( + (acc, line, index) => { + const hunk = parse(line) + if (hunk) { + return { files: acc.files, old: hunk.old, next: hunk.next } + } + + if ( + acc.old !== 0 && + acc.next !== 0 && + line.startsWith("--- ") && + lines[index + 1]?.startsWith("+++ ") && + lines[index + 2]?.startsWith("@@") + ) { + return { files: [...acc.files, index], old: 0, next: 0 } + } + + if (acc.old === 0 && acc.next === 0) { + if (line.startsWith("--- ") && lines[index + 1]?.startsWith("+++ ")) { + return { files: [...acc.files, index], old: 0, next: 0 } + } + return acc + } + + if (line.startsWith("\\ ")) return acc + if (line.startsWith("+")) { + return { files: acc.files, old: acc.old, next: acc.next - 1 } + } + if (line.startsWith("-")) { + return { files: acc.files, old: acc.old - 1, next: acc.next } + } + return { files: acc.files, old: acc.old - 1, next: acc.next - 1 } + }, + { files: [] as number[], old: 0, next: 0 }, + ).files + + if (files.length === 0) { + const hunks = split(lines) + if (hunks.length <= 1) return [diff] + return hunks + } + + const hunks = files + .map((line, index) => { + const start = index === 0 ? 0 : line + const end = files[index + 1] ?? lines.length + return split(lines.slice(start, end)) + }) + .flat() + + if (hunks.length <= 1) return [diff] + return hunks +} diff --git a/packages/opencode/test/kilocode/tui-diff.test.ts b/packages/opencode/test/kilocode/tui-diff.test.ts new file mode 100644 index 00000000000..ccc0454192b --- /dev/null +++ b/packages/opencode/test/kilocode/tui-diff.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test" +import { splitDiffHunks } from "../../src/kilocode/tui/diff" + +describe("splitDiffHunks", () => { + test("returns original diff when there are no hunks", () => { + const diff = "--- a/file.ts\n+++ b/file.ts" + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("returns original diff when there is one hunk", () => { + const diff = ["--- a/file.ts", "+++ b/file.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE", " two"].join("\n") + + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("ignores header-like content lines inside a hunk", () => { + const diff = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,1 +1,1 @@", + "--- not-a-header", + "+++ still-not-a-header", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([diff]) + }) + + test("splits multi-hunk diff and preserves headers", () => { + const diff = [ + "--- a/file.ts", + "+++ b/file.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + " two", + "@@ -10,2 +10,2 @@", + "-ten", + "+TEN", + " eleven", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/file.ts", "+++ b/file.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE", " two"].join("\n"), + ["--- a/file.ts", "+++ b/file.ts", "@@ -10,2 +10,2 @@", "-ten", "+TEN", " eleven"].join("\n"), + ]) + }) + + test("splits concatenated multi-file diff with per-file headers", () => { + const diff = [ + "--- a/one.ts", + "+++ b/one.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + "--- a/two.ts", + "+++ b/two.ts", + "@@ -1,2 +1,2 @@", + "-two", + "+TWO", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/one.ts", "+++ b/one.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE"].join("\n"), + ["--- a/two.ts", "+++ b/two.ts", "@@ -1,2 +1,2 @@", "-two", "+TWO"].join("\n"), + ]) + }) + + test("splits multi-file diff when first file has multiple hunks", () => { + const diff = [ + "--- a/one.ts", + "+++ b/one.ts", + "@@ -1,2 +1,2 @@", + "-one", + "+ONE", + "@@ -10,2 +10,2 @@", + "-ten", + "+TEN", + "--- a/two.ts", + "+++ b/two.ts", + "@@ -3,2 +3,2 @@", + "-two", + "+TWO", + ].join("\n") + + expect(splitDiffHunks(diff)).toEqual([ + ["--- a/one.ts", "+++ b/one.ts", "@@ -1,2 +1,2 @@", "-one", "+ONE"].join("\n"), + ["--- a/one.ts", "+++ b/one.ts", "@@ -10,2 +10,2 @@", "-ten", "+TEN"].join("\n"), + ["--- a/two.ts", "+++ b/two.ts", "@@ -3,2 +3,2 @@", "-two", "+TWO"].join("\n"), + ]) + }) +}) From 681838bb780790a018eb2356b6632f687d3052b3 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 7 May 2026 14:45:33 +0000 Subject: [PATCH 11/66] fix: restore root package.json entries dropped by upstream-compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the `postinstall` tail (`bun run script/setup-git.ts`, needed for repo-local `merge.conflictStyle=zdiff3`) and the `dev-setup` script shorthand, and drops `dev:desktop` / `dev:web` / `dev:console` plus `@sentry/solid` and `@sentry/vite-plugin` catalog entries — none of which have Kilo consumers. Refs #10003. --- package.json | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 045bd8676a0..9f1d0a5176c 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,11 @@ "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop-electron dev", - "dev:web": "bun --cwd packages/app dev", - "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", + "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup", "dev:storybook": "bun --cwd packages/storybook storybook", "lint": "oxlint", "typecheck": "bun turbo typecheck", - "postinstall": "bun run --cwd packages/opencode fix-node-pty", + "postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts", "prepare": "husky", "random": "echo 'Random script'", "hello": "echo 'Hello World!'", @@ -76,8 +74,6 @@ "@solidjs/meta": "0.29.4", "@solidjs/router": "0.15.4", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", - "@sentry/solid": "10.36.0", - "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.10" From c87bf9f078441440e8273fcc79a3b9907005f4c7 Mon Sep 17 00:00:00 2001 From: Ligia Zanchet Date: Fri, 8 May 2026 18:01:31 +0200 Subject: [PATCH 12/66] Update auto-docs workflow for PR handling --- .github/workflows/auto-docs.yml | 114 +++++++++++++++++++++++++++----- 1 file changed, 98 insertions(+), 16 deletions(-) diff --git a/.github/workflows/auto-docs.yml b/.github/workflows/auto-docs.yml index 4364ee87480..d4ee1d2aba4 100644 --- a/.github/workflows/auto-docs.yml +++ b/.github/workflows/auto-docs.yml @@ -1,35 +1,117 @@ -name: Trigger webhook for feat PRs +name: Auto-docs dry-run intake +# Trigger on merged feature PRs, or manually with a specific PR number. +# IMPORTANT: This workflow intentionally does NOT check out PR code and does NOT +# execute any code from the PR branch. It only reads PR metadata and forwards it +# to a webhook. This is required for safe use of pull_request_target on a public repo. on: - pull_request: + pull_request_target: types: [closed] + workflow_dispatch: + inputs: + pr_number: + description: "PR number to test" + required: true + +# Minimal permissions: read repo contents (for gh api) and PR metadata only. +permissions: + contents: read + pull-requests: read + +# One run per PR at a time. Does not cancel in progress — let the current run +# finish so we don't drop events when a PR is quickly closed/reopened/closed. +concurrency: + group: auto-docs-${{ github.event.pull_request.number || inputs.pr_number }} + cancel-in-progress: false jobs: - call-webhook: + call-valtown: if: > - github.event.pull_request.merged == true && - (startsWith(github.event.pull_request.title, 'feat:') || - startsWith(github.event.pull_request.title, 'feat(')) + github.event_name == 'workflow_dispatch' || + ( + github.event.pull_request.merged == true && + ( + startsWith(github.event.pull_request.title, 'feat:') || + startsWith(github.event.pull_request.title, 'feat(') + ) + ) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: - - name: Send webhook safely + # On manual dispatch, fetch real PR metadata via the GitHub API so that + # manual runs are full end-to-end tests rather than "ping only" runs. + # The fetched fields are written to GITHUB_ENV and picked up by the next + # step via the `env.TITLE` etc. fallbacks in its `env:` block. + - name: Fetch PR metadata (manual dispatch) + if: github.event_name == 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ inputs.pr_number }} + REPO: ${{ github.repository }} + run: | + gh api "repos/$REPO/pulls/$PR_NUMBER" > pr.json + { + echo "TITLE=$(jq -r .title pr.json)" + echo "BODY<<__EOF__" + jq -r '.body // ""' pr.json + echo "__EOF__" + echo "AUTHOR=$(jq -r .user.login pr.json)" + echo "MERGED_AT=$(jq -r '.merged_at // ""' pr.json)" + echo "PR_URL=$(jq -r .html_url pr.json)" + } >> "$GITHUB_ENV" + + - name: Build webhook payload + env: + EVENT_NAME: ${{ github.event_name }} + REPO: ${{ github.repository }} + # For pull_request_target the left-hand side is populated by the event. + # For workflow_dispatch it evaluates to empty, so we fall back to the + # values written to GITHUB_ENV by the "Fetch PR metadata" step above. + PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + TITLE: ${{ github.event.pull_request.title || env.TITLE }} + BODY: ${{ github.event.pull_request.body || env.BODY }} + AUTHOR: ${{ github.event.pull_request.user.login || env.AUTHOR }} + MERGED_AT: ${{ github.event.pull_request.merged_at || env.MERGED_AT }} + PR_URL: ${{ github.event.pull_request.html_url || env.PR_URL }} run: | payload=$(jq -n \ - --arg repo "${GITHUB_REPOSITORY}" \ - --arg pr_number "${{ github.event.pull_request.number }}" \ - --arg title "${{ github.event.pull_request.title }}" \ - --arg body "${{ github.event.pull_request.body }}" \ - --arg author "${{ github.event.pull_request.user.login }}" \ - --arg merged_at "${{ github.event.pull_request.merged_at }}" \ + --arg event_name "$EVENT_NAME" \ + --arg repo "$REPO" \ + --arg pr_number "$PR_NUMBER" \ + --arg title "$TITLE" \ + --arg body "$BODY" \ + --arg author "$AUTHOR" \ + --arg merged_at "$MERGED_AT" \ + --arg pr_url "$PR_URL" \ '{ + event_name: $event_name, repo: $repo, pr_number: $pr_number, title: $title, description: $body, author: $author, - merged_at: $merged_at + merged_at: $merged_at, + pr_url: $pr_url }' ) - curl --fail-with-body -X POST "${{ secrets.DOC_WEBHOOK_URL }}" \ + + echo "$payload" > payload.json + echo "Payload:" + cat payload.json + + - name: Send webhook to Val Town + env: + DOC_WEBHOOK_URL: ${{ secrets.DOC_WEBHOOK_URL }} + DOC_WEBHOOK_SECRET: ${{ secrets.DOC_WEBHOOK_SECRET }} + run: | + if [ -z "$DOC_WEBHOOK_URL" ]; then + echo "DOC_WEBHOOK_URL secret is not set" + exit 1 + fi + + curl --fail-with-body -sS -X POST "$DOC_WEBHOOK_URL" \ -H "Content-Type: application/json" \ - --data-raw "$payload" + -H "X-Docs-Webhook-Secret: $DOC_WEBHOOK_SECRET" \ + --data-binary @payload.json From 710c9dca1e9d5f522fa3fe1495dc41ec1eea4b66 Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 9 May 2026 14:57:09 -0400 Subject: [PATCH 13/66] fix(vscode): sync mode model selection --- .changeset/vscode-mode-model-sync.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 15 +++- .../tests/unit/session-model-store.test.ts | 72 +++++++++++++++++++ .../webview-ui/src/context/session.tsx | 45 ++++++++---- 4 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 .changeset/vscode-mode-model-sync.md diff --git a/.changeset/vscode-mode-model-sync.md b/.changeset/vscode-mode-model-sync.md new file mode 100644 index 00000000000..142de1d15ab --- /dev/null +++ b/.changeset/vscode-mode-model-sync.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep configured mode models in sync when switching modes in VS Code. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index cf3c33479e2..41d78d0b376 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -2356,6 +2356,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper partial.provider !== undefined || partial.disabled_providers !== undefined || partial.enabled_providers !== undefined + const refreshAgents = + partial.default_agent !== undefined || + partial.agent !== undefined || + project.default_agent !== undefined || + project.agent !== undefined const hasGlobal = Object.keys(partial).length > 0 const hasProject = Object.keys(project).length > 0 @@ -2388,7 +2393,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper globalConfig: global, features: configFeatures(merged), }) - if (refreshProviders) await this.fetchAndSendProviders() + await Promise.all([ + refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), + refreshAgents ? this.fetchAndSendAgents() : Promise.resolve(), + ]) } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) const patch = @@ -3091,9 +3099,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Config was updated without a full dispose (e.g. permission-only save). - // Fetch and push the updated config so the Settings panel reflects the change. + // Fetch and push the updated config + refresh agents and providers so the + // Settings panel and mode/model pickers reflect the change. if (event.type === "global.config.updated") { - void this.fetchAndSendConfigUpdated() + void Promise.all([this.fetchAndSendConfigUpdated(), this.fetchAndSendAgents(), this.fetchAndSendProviders()]) return } diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 66409719064..01f862b9bd3 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -216,4 +216,76 @@ describe("per-mode model memory", () => { expect(result.modelSelections["code"]).toEqual(claude) expect(Object.keys(result.sessionOverrides)).toHaveLength(0) }) + + it("clearing both session override and per-mode selection falls back to config default", () => { + let store = emptyStore() + // Simulate mode model set in config (getModeModel returns claude). + const configured: ResolveEnv = { + ...env(), + getModeModel: (name) => (name === "code" ? claude : null), + } + + // User picked gpt globally for "code" + let result = applyModel(store, "code", gpt, undefined) + store = { ...store, ...result } + + // User then overrode the session with claude + result = applyModel(store, "code", claude, "session-a") + store = { ...store, ...result } + + // Simulate clearModelOverride: clear both session override and per-mode selection + const reset: ModelStore = { + ...store, + sessionOverrides: {}, + modelSelections: { ...store.modelSelections, code: null }, + } + + // Should fall through to the configured per-mode model (claude from config) + expect(getSelected(reset, configured, "session-a", "code")).toEqual(claude) + }) + + it("clearing only session override but not per-mode selection leaves persisted pick visible", () => { + let store = emptyStore() + const e = env() + + // User picked claude globally for "code" + const result = applyModel(store, "code", claude, undefined) + store = { ...store, ...result } + + // User then overrode the session with gpt + const r2 = applyModel(store, "code", gpt, "session-a") + store = { ...store, ...r2 } + + // Simulate OLD behaviour: only clear session override, leave modelSelections intact + const partial: ModelStore = { ...store, sessionOverrides: {} } + + // The persisted per-mode selection (claude) is still returned — this is + // why the reset appeared to "do nothing" when config also resolved to claude. + expect(getSelected(partial, e, "session-a", "code")).toEqual(claude) + }) + + it("switching from plan to implementation uses implementation config after clearing stale memory", () => { + let store = emptyStore() + const configured: ResolveEnv = { + ...env(), + getModeModel: (name) => (name === "code" ? gpt : name === "plan" ? claude : null), + } + + // Old manual memory says implementation/code should use claude. + let result = applyModel(store, "code", claude, undefined) + store = { ...store, ...result } + + // Current plan session is using its own model. + result = applyModel(store, "plan", claude, "session-a") + store = { ...store, ...result, agentSelections: { "session-a": "plan" } } + + const switched: ModelStore = { + ...store, + agentSelections: { "session-a": "code" }, + sessionOverrides: {}, + modelSelections: { ...store.modelSelections, code: null }, + } + + expect(getSelected(switched, configured, "session-a", "code")).toEqual(gpt) + }) }) diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 8ebe0d41edf..77260855bf9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -516,6 +516,25 @@ export const SessionProvider: ParentComponent = (props) => { }) } + function clearModeModelSelection(agentName: string, persist = false) { + setUserSetAgents((prev) => { + const next = { ...prev } + delete next[agentName] + return next + }) + setStore( + "modelSelections", + produce((selections) => { + delete selections[agentName] + }), + ) + if (persist) vscode.postMessage({ type: "clearModelSelection", agent: agentName }) + } + + function shouldClearModeModelSelection(agentName: string) { + return getModeModel(agentName) !== null && userSetAgents()[agentName] === true + } + function clearHiddenErrors(ids: string[]) { if (ids.length === 0) return setHiddenErrors((prev) => { @@ -542,6 +561,10 @@ export const SessionProvider: ParentComponent = (props) => { /** Clear the per-mode model override, falling back to config default. */ function clearModelOverride(sessionID?: string) { const sid = sessionID ?? currentSessionID() + const agentName = sid ? agentForScope(sid) : selectedAgentName() + // Always clear the persisted per-mode model selection so the user's + // configured (or fallback) model becomes effective, not the last manual pick. + clearModeModelSelection(agentName, true) if (sid) { setStore( "sessionOverrides", @@ -550,22 +573,7 @@ export const SessionProvider: ParentComponent = (props) => { }), ) hideErrors(sid) - return } - const agentName = selectedAgentName() - setUserSetAgents((prev) => { - const next = { ...prev } - delete next[agentName] - return next - }) - setStore( - "modelSelections", - produce((selections) => { - delete selections[agentName] - }), - ) - // Clear from model.json via extension host - vscode.postMessage({ type: "clearModelSelection", agent: agentName }) } // Handle agentsLoaded immediately (not in onMount) so we never miss @@ -1692,8 +1700,15 @@ export const SessionProvider: ParentComponent = (props) => { delete overrides[id] }), ) + if (shouldClearModeModelSelection(name)) { + clearModeModelSelection(name) + } } else { setPendingAgentSelection(name) + if (shouldClearModeModelSelection(name)) { + clearModeModelSelection(name) + return + } // When switching mode, initialize model for the new mode if the user // hasn't explicitly set one for it if (!userSetAgents()[name] && !store.modelSelections[name]) { From b539c6920ee779b4323216e2bfdc571c25fbfbde Mon Sep 17 00:00:00 2001 From: kirillk Date: Sat, 9 May 2026 20:49:26 -0400 Subject: [PATCH 14/66] fix(vscode): add mode field to plan follow-up Continue here option so picker updates instantly --- .../tests/unit/question-dock-utils.test.ts | 61 +++++++++++++++++++ .../opencode/src/kilocode/plan-followup.ts | 1 + packages/opencode/src/question/index.ts | 5 ++ .../test/kilocode/plan-followup.test.ts | 28 +++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++ packages/sdk/openapi.json | 4 ++ 6 files changed, 103 insertions(+) diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts index aa5781dea1d..9b882d03957 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -105,6 +105,67 @@ describe("resolveSelectedQuestionMode", () => { }) }) +describe("plan follow-up Continue here option", () => { + it("resolves to code mode when option carries mode: code matching the Continue here label", () => { + const questions = [ + { + options: [ + { + label: "Start new session", + description: "Implement in a fresh session", + labelKey: "plan.followup.answer.newSession", + descriptionKey: "plan.followup.answer.newSession.description", + }, + { + label: "Continue here", + description: "Implement the plan in this session", + labelKey: "plan.followup.answer.continue", + descriptionKey: "plan.followup.answer.continue.description", + mode: "code", + }, + ], + }, + ] + expect(resolveSelectedQuestionMode(questions, [["Continue here"]])).toBe("code") + }) + + it("does not resolve a mode for Start new session (it carries no mode)", () => { + const questions = [ + { + options: [ + { + label: "Start new session", + description: "Implement in a fresh session", + labelKey: "plan.followup.answer.newSession", + descriptionKey: "plan.followup.answer.newSession.description", + }, + { + label: "Continue here", + description: "Implement the plan in this session", + labelKey: "plan.followup.answer.continue", + descriptionKey: "plan.followup.answer.continue.description", + mode: "code", + }, + ], + }, + ] + expect(resolveSelectedQuestionMode(questions, [["Start new session"]])).toBeUndefined() + }) + + it("resolves optimistic agent to code when Continue here is picked while in plan mode", () => { + const mode = resolveSelectedQuestionMode( + [ + { + options: [{ label: "Continue here", description: "Implement here", mode: "code" }], + }, + ], + [["Continue here"]], + ) + const result = resolveOptimisticQuestionAgent(undefined, "plan", mode) + expect(result).toEqual({ base: "plan", agent: "code" }) + }) +}) + describe("resolveOptimisticQuestionAgent", () => { it("stores the previous agent when applying an optimistic mode", () => { const result = resolveOptimisticQuestionAgent(undefined, "ask", "code") diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 71b226a820a..24f43c74170 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -297,6 +297,7 @@ export namespace PlanFollowup { labelKey: "plan.followup.answer.continue", description: "Implement the plan in this session", descriptionKey: "plan.followup.answer.continue.description", + mode: "code", }, ], }, diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index fe9271a5aae..a0d6a407afd 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -29,6 +29,11 @@ export class Option extends Schema.Class