diff --git a/.changeset/cli-sidebar-pr.md b/.changeset/cli-sidebar-pr.md new file mode 100644 index 00000000000..3e3bad0cd3c --- /dev/null +++ b/.changeset/cli-sidebar-pr.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Show the open GitHub PR for the current branch in the session sidebar. diff --git a/.changeset/deepseek-empty-reasoning.md b/.changeset/deepseek-empty-reasoning.md new file mode 100644 index 00000000000..3ce8eebd8af --- /dev/null +++ b/.changeset/deepseek-empty-reasoning.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix multi-turn tool calls with DeepSeek thinking mode by preserving empty `reasoning_content` in the interleaved transform. diff --git a/AGENTS.md b/AGENTS.md index 3ff62660676..8c68695e79c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,6 +181,15 @@ Changeset descriptions appear directly in release notes and are read by end user PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself. +## GitHub Issues + +- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. +- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title. +- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25 +- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 +- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title. +- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. + ## Fork Merge Process Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode). diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 0b50db807e8..3da24107350 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2299,6 +2299,53 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kilo-desktop" +version = "0.0.0" +dependencies = [ + "chrono", + "comrak", + "dirs", + "futures", + "gtk", + "listeners", + "objc2 0.6.3", + "objc2-web-kit", + "process-wrap", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build 2.5.2", + "tauri-plugin-clipboard-manager", + "tauri-plugin-decorum", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tauri-specta", + "tokio", + "tokio-stream", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "webkit2gtk", + "windows-core 0.62.2", + "windows-sys 0.61.2", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3093,53 +3140,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "kilo-desktop" -version = "0.0.0" -dependencies = [ - "chrono", - "comrak", - "dirs", - "futures", - "gtk", - "listeners", - "objc2 0.6.3", - "objc2-web-kit", - "process-wrap", - "reqwest 0.12.24", - "semver", - "serde", - "serde_json", - "specta", - "specta-typescript", - "tauri", - "tauri-build 2.5.2", - "tauri-plugin-clipboard-manager", - "tauri-plugin-decorum", - "tauri-plugin-deep-link", - "tauri-plugin-dialog", - "tauri-plugin-http", - "tauri-plugin-notification", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-single-instance", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-plugin-window-state", - "tauri-specta", - "tokio", - "tokio-stream", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uuid", - "webkit2gtk", - "windows-core 0.62.2", - "windows-sys 0.61.2", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -4163,9 +4163,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 0c4eb040359..f21d745e283 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -76,6 +76,61 @@ Check [our provider docs](/docs/ai-providers) for specific context limits on eac **Recover from context limit errors:** If you hit the `input length and max tokens exceed context limit` error, you can recover by deleting a message, rolling back to a previous checkpoint, or switching over to a model with a long context window like Gemini for a message. {% /callout %} +## Models During Delegation + +When an agent delegates work to a subagent (via the `task` tool), the subagent **inherits the parent agent's model** by default. You can override this per subagent in your config: + +{% tabs %} +{% tab label="CLI" %} + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. + +{% /tab %} +{% tab label="VSCode" %} + +Subagents inherit the model currently active in the primary agent session — the model shown in the selector at the bottom of the chat. To bypass inheritance and pin a specific model for a subagent: + +- **Via Settings** — open **Settings → Models → Model per Mode**, find the subagent, and pick its model. +- **Via config file** — edit `kilo.jsonc`: + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-5" + } + } +} +``` + +The Settings UI writes the same `agent..model` entry, so either method produces the same override. Subagents without an explicit model continue to inherit whatever the invoking agent is running. + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +In the legacy extension, each mode has **Sticky Models** — switching from one mode to another (e.g., Code → Architect) uses whatever model you last selected for that mode, not the model from the mode you came from. This means you can assign different models to different modes: + +- **Architect:** a reasoning-heavy model (Gemini Pro, Claude Opus) +- **Code:** a fast coding model (Claude Sonnet, GPT-4.1) +- **Debug:** a cost-efficient model (Gemini Flash, DeepSeek) + +The model selection is remembered per mode across sessions. + +{% /tab %} +{% /tabs %} + +For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents). + ## Stay Current The AI model space moves fast. Bookmark [kilo.ai/models](https://kilo.ai/models) and check back when you're evaluating options. What's best today might not be best next month — and that's actually exciting. diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index f5a2089503e..c1d12f05117 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -232,11 +232,27 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Locations + +The current VSCode extension reads the legacy `custom_modes.yaml` file from its own global storage directory. Helpful for inspecting or fixing the file before the one-time migration runs: + +| OS | Path | +| ------- | ----------------------------------------------------------------------------------------------------- | +| macOS | `~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Linux | `~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Windows | `%APPDATA%\Code\User\globalStorage\kilocode.kilo-code\settings\custom_modes.yaml` | + +Project-level `.kilocodemodes` and workspace-scoped files are handled by the CLI backend that the extension delegates to — see the [CLI tab](#cli) for the full load-order table. After the extension migrates on startup, the legacy file is no longer consulted; remove new modes through the extension UI instead of editing `custom_modes.yaml` directly. + {% /tab %} {% tab label="CLI" %} In the CLI, custom behavioral profiles are called **agents** instead of modes. Agents are defined as Markdown files with YAML frontmatter or as entries in the `agent` key of your config file. +{% callout type="warning" %} +**Legacy `custom_modes.yaml` is not loaded from `~/.config/kilo/`.** If you're migrating from the legacy VSCode extension, global custom modes are read from `~/.kilocode/cli/global/settings/custom_modes.yaml` (not from the CLI's XDG config directory). The recommended approach is to convert legacy modes to agent `.md` files and place them in `~/.config/kilo/agent/` instead — see [Markdown files](#3-markdown-files-with-yaml-frontmatter) and [Migration](#migration-from-vscode-extension-modes) below. +{% /callout %} + ## What's Included in a Custom Agent? | Property | Description | @@ -452,6 +468,21 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Lookup Paths + +The CLI reads legacy mode files from the following locations (in load order). When the same slug appears in multiple sources, the **last loaded source wins**: + +| Load Order | Path | Format | Scope | +|------------|------|--------|-------| +| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | YAML | Global | +| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | YAML | Global | +| 3 | `~/.kilocodemodes` | YAML | Global | +| 4 | `/.kilocodemodes` | YAML | Project (wins on conflict) | + +{% callout type="info" %} +`~/.config/kilo/` is the XDG config directory for the new agent format — legacy `custom_modes.yaml` placed there will **not** be loaded. Use `~/.config/kilo/agent/*.md` or `~/.config/kilo/kilo.jsonc` for new agent definitions instead. +{% /callout %} + {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7941b63bc0e..9c5bd8bf7ab 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -105,7 +105,7 @@ "@effect/platform-node": "catalog:", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", + "@hono/node-server": "1.19.13", "@hono/node-ws": "1.3.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 6a7892af7d4..1b256cd9390 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news" import HomeOnboarding from "@/kilocode/plugins/home-onboarding" import KiloHomeFooter from "@/kilocode/plugins/home-footer" import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer" +import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr" import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage" // kilocode_change end import SidebarContext from "../feature-plugins/sidebar/context" @@ -26,6 +27,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ HomeOnboarding, // kilocode_change KiloHomeFooter, // kilocode_change KiloSidebarFooter, // kilocode_change + KiloSidebarPr, // kilocode_change KiloSidebarUsage, // kilocode_change HomeFooter, HomeTips, diff --git a/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx new file mode 100644 index 00000000000..1da6b70ab98 --- /dev/null +++ b/packages/opencode/src/kilocode/plugins/sidebar-pr.tsx @@ -0,0 +1,286 @@ +// kilocode_change - new file +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" +import { createMemo, createResource, Show } from "solid-js" +import { Process } from "@/util" + +const id = "internal:kilo-sidebar-pr" +const GH_PROBE_TTL = 300_000 + +type Pr = { number: number; title: string } +type Item = Partial & { headRefOid?: string } +type Repo = { + nameWithOwner?: unknown + parent?: { nameWithOwner?: unknown; name?: unknown; owner?: { login?: unknown } } | null +} + +let ghPath: string | null | undefined +let ghProbeTime = 0 + +function wait(ms: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const done = () => { + clearTimeout(id) + signal.removeEventListener("abort", stop) + } + + const stop = () => { + done() + reject(signal.reason) + } + + const id = setTimeout(() => { + done() + resolve() + }, ms) + + signal.addEventListener("abort", stop, { once: true }) + }) +} + +async function lookup(cwd: string, branch: string): Promise { + const ctrl = new AbortController() + const deadline = wait(20_000, ctrl.signal).then(() => ctrl.abort()) + + try { + if (!probeGh()) return null + + // Try the tracking ref first (works when PR was checked out via `gh pr checkout` + // or when the branch's upstream is a fork). Fall back to an explicit branch + // lookup (works for same-repo branches pushed to origin). + const build = (b?: string) => { + const a = ["gh", "pr", "view"] + if (b) a.push(b) + a.push("--json", "number,title") + return a + } + for (const cmd of [build(), build(branch)]) { + const res = await Process.text(cmd, { + cwd, + abort: ctrl.signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) continue + const text = res.text.trim() + if (!text) continue + const data = JSON.parse(text) as Partial + if (typeof data.number === "number" && typeof data.title === "string") { + return { number: data.number, title: data.title } + } + } + + const head = await lookupHead(cwd, ctrl.signal) + if (!head) return null + + const local = await lookupBySha(cwd, head, undefined, ctrl.signal) + if (local) return local + + const parent = await lookupParent(cwd, ctrl.signal) + if (!parent) return null + + const pr = await lookupByHead(cwd, branch, head, parent, ctrl.signal) + if (pr) return pr + + return await lookupBySha(cwd, head, parent, ctrl.signal) + } finally { + ctrl.abort() + await deadline.catch(() => undefined) + } +} + +async function lookupHead(cwd: string, signal: AbortSignal): Promise { + const res = await Process.text(["git", "rev-parse", "HEAD"], { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) return null + + const head = res.text.trim() + if (!head) return null + + return head +} + +async function lookupParent(cwd: string, signal: AbortSignal): Promise { + const res = await Process.text(["gh", "repo", "view", "--json", "nameWithOwner,parent"], { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + try { + const data = JSON.parse(text) as Repo + if (typeof data.nameWithOwner !== "string") return null + if (!data.parent) return null + + const parent = + typeof data.parent.nameWithOwner === "string" + ? data.parent.nameWithOwner + : typeof data.parent.owner?.login === "string" && typeof data.parent.name === "string" + ? `${data.parent.owner.login}/${data.parent.name}` + : null + if (!parent || parent === data.nameWithOwner) return null + + return parent + } catch { + return null + } +} + +async function lookupByHead( + cwd: string, + branch: string, + head: string, + repo: string, + signal: AbortSignal, +): Promise { + const res = await Process.text( + [ + "gh", + "pr", + "list", + "-R", + repo, + "--state", + "open", + "--head", + branch, + "--limit", + "10", + "--json", + "number,title,headRefOid", + ], + { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }, + ) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + return select(text, head) +} + +async function lookupBySha( + cwd: string, + head: string, + repo: string | undefined, + signal: AbortSignal, +): Promise { + const cmd = [ + "gh", + "pr", + "list", + ...(repo ? ["-R", repo] : []), + "--state", + "open", + "--search", + `${head} is:pr`, + "--limit", + "5", + "--json", + "number,title,headRefOid", + ] + const res = await Process.text(cmd, { + cwd, + abort: signal, + nothrow: true, + timeout: 1_000, + }) + if (res.code !== 0) return null + + const text = res.text.trim() + if (!text) return null + + return select(text, head) +} + +function select(text: string, head: string): Pr | null { + try { + const items = JSON.parse(text) as Item[] + if (!Array.isArray(items) || items.length === 0) return null + + // Only accept a PR whose HEAD matches ours exactly — avoids returning a + // random PR that merely references the SHA in a commit message. + for (const item of items) { + if (item.headRefOid !== head) continue + if (typeof item.number !== "number") continue + if (typeof item.title !== "string") continue + return { number: item.number, title: item.title } + } + + return null + } catch { + return null + } +} + +function probeGh(): string | null { + const now = Date.now() + if (ghPath !== undefined && now - ghProbeTime < GH_PROBE_TTL) return ghPath + ghPath = Bun.which("gh") ?? null + ghProbeTime = now + return ghPath +} + +function View(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const branch = createMemo(() => props.api.state.vcs?.branch) + const cwd = createMemo(() => props.api.state.path.directory) + + // Primitive string key: createResource wraps source in createMemo and + // compares with === for equality, so the same inputs produce a stable key + // and the fetcher is not retriggered on every render. + const key = createMemo(() => { + const b = branch() + const d = cwd() + if (!b || !d) return false as const + return `${d}\0${b}` + }) + + const [pr] = createResource(key, async (k) => { + if (!k) return null + const [d, b] = k.split("\0") + if (!d || !b) return null + return lookup(d, b).catch(() => null) + }) + + // The wrapper must be present unconditionally — the OpenTUI slot + // registry relies on a stable root node per plugin. Gating the whole tree + // on `` breaks the mount. Conditionally render only the inner text. + return ( + + + + PR #{pr()!.number} - {pr()!.title} + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 50, + slots: { + sidebar_content(_ctx, _props) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { id, tui } + +export default plugin diff --git a/packages/opencode/src/kilocode/tool/task.ts b/packages/opencode/src/kilocode/tool/task.ts index 417c17d0085..89cfc33f84b 100644 --- a/packages/opencode/src/kilocode/tool/task.ts +++ b/packages/opencode/src/kilocode/tool/task.ts @@ -1,8 +1,22 @@ // kilocode_change - new file +import { Effect } from "effect" +import path from "path" import { Permission } from "@/permission" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { ModelID, ProviderID } from "@/provider/schema" import type { Session } from "../../session" import type { Agent } from "../../agent/agent" import type { Config } from "../../config" +import z from "zod" + +// RATIONALE: Mirror narrow state slice Task tool consumes and ignore unrelated TUI fields. +const ModelState = z + .object({ + model: z.record(z.string(), z.object({ providerID: ProviderID.zod, modelID: ModelID.zod })).optional(), + variant: z.record(z.string(), z.string().optional()).optional(), + }) + .passthrough() export namespace KiloTask { /** Reject primary agents used as subagents */ @@ -35,4 +49,25 @@ export namespace KiloTask { export function permissions(rules: Permission.Ruleset): Permission.Ruleset { return [{ permission: "task", pattern: "*", action: "deny" }, ...rules] } + + /** Return saved CLI model for agent, if any. */ + export const resolveModel = Effect.fn("KiloTask.resolveModel")(function* (name: string) { + if (Flag.KILO_CLIENT !== "cli") return undefined + const file = path.join(Global.Path.state, "model.json") + const state = yield* Effect.tryPromise({ + try: () => + Bun.file(file) + .text() + .then((raw) => ModelState.safeParse(JSON.parse(raw))) + .then((result) => (result.success ? result.data : undefined)) + .catch(() => undefined), + catch: () => undefined, + }) + const model = state?.model?.[name] + if (!model) return undefined + return { + ...model, + variant: state?.variant?.[`${model.providerID}/${model.modelID}`], + } + }) } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index cd0de1505a6..f3dee7e6ec4 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -193,25 +193,23 @@ function normalizeMessages( // Filter out reasoning parts from content const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") - // Include reasoning_content | reasoning_details directly on the message for all assistant messages - if (reasoningText) { - return { - ...msg, - content: filteredContent, - providerOptions: { - ...msg.providerOptions, - openaiCompatible: { - ...msg.providerOptions?.openaiCompatible, - [field]: reasoningText, - }, - }, - } - } - + // kilocode_change start - cherry-picked from anomalyco/opencode#24146; + // will be reverted on the next wholesale upstream merge. + // Include reasoning_content | reasoning_details directly on the message for all assistant messages. + // Always set the field even when empty — some providers (e.g. DeepSeek) may return empty + // reasoning_content which still needs to be sent back in subsequent requests. return { ...msg, content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...msg.providerOptions?.openaiCompatible, + [field]: reasoningText, + }, + }, } + // kilocode_change end } return msg diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2b678f04a31..aed95c9f12f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -112,16 +112,22 @@ export const TaskTool = Tool.define( const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })) if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message")) - const model = next.model ?? { - modelID: msg.info.modelID, - providerID: msg.info.providerID, - } + // kilocode_change start — prefer user's CLI-saved pick for this subagent + const saved = yield* KiloTask.resolveModel(next.name) + const model = saved ?? + next.model ?? { + modelID: msg.info.modelID, + providerID: msg.info.providerID, + } + const variant = saved?.variant ?? (saved ? undefined : next.variant) + // kilocode_change end yield* ctx.metadata({ title: params.description, metadata: { sessionId: nextSession.id, model, + variant, // kilocode_change }, }) @@ -148,6 +154,7 @@ export const TaskTool = Tool.define( modelID: model.modelID, providerID: model.providerID, }, + variant, // kilocode_change agent: next.name, tools: { ...(canTodo ? {} : { todowrite: false }), @@ -162,6 +169,7 @@ export const TaskTool = Tool.define( metadata: { sessionId: nextSession.id, model, + variant, // kilocode_change }, output: [ `task_id: ${nextSession.id} (for resuming to continue this task if needed)`, diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts new file mode 100644 index 00000000000..b19fe1c8ff2 --- /dev/null +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -0,0 +1,352 @@ +import { afterEach, beforeAll, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import fs from "fs/promises" +import path from "path" +import { Agent } from "../../src/agent/agent" +import { Config } from "../../src/config" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Global } from "../../src/global" +import { Instance } from "../../src/project/instance" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import type { SessionPrompt } from "../../src/session/prompt" +import { MessageID, PartID } from "../../src/session/schema" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { TaskTool, type TaskPromptOps } from "../../src/tool/task" +import { Truncate } from "../../src/tool" +import { ToolRegistry } from "../../src/tool" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const state = path.join(Global.Path.state, "model.json") + +afterEach(async () => { + process.env.KILO_CLIENT = "cli" + await fs.rm(state, { force: true }).catch(() => undefined) + await Instance.disposeAll() +}) + +beforeAll(async () => { + process.env.KILO_CLIENT = "cli" + await fs.rm(state, { force: true }).catch(() => undefined) +}) + +const parent = { + providerID: ProviderID.make("parent-provider"), + modelID: ModelID.make("parent-model"), +} + +const saved = { + providerID: ProviderID.make("saved-provider"), + modelID: ModelID.make("saved-model"), +} + +const cfg = { + providerID: ProviderID.make("config-provider"), + modelID: ModelID.make("config-model"), +} + +const savedVariant = "fast" +const cfgVariant = "balanced" + +const it = testEffect( + Layer.mergeAll( + Agent.defaultLayer, + Config.defaultLayer, + CrossSpawnSpawner.defaultLayer, + Session.defaultLayer, + Truncate.defaultLayer, + ToolRegistry.defaultLayer, + ), +) + +const seed = Effect.fn("TaskToolModelTest.seed")(function* (title = "Parent") { + const session = yield* Session.Service + const chat = yield* session.create({ title }) + const user = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: parent, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: parent.modelID, + providerID: parent.providerID, + time: { created: Date.now() }, + } + yield* session.updateMessage(assistant) + return { chat, assistant } +}) + +function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { + return { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.sync(() => { + opts?.onPrompt?.(input) + return reply(input, opts?.text ?? "done") + }), + } +} + +function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: input.agent ?? "general", + agent: input.agent ?? "general", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: input.model?.modelID ?? parent.modelID, + providerID: input.model?.providerID ?? parent.providerID, + time: { created: Date.now() }, + finish: "stop", + }, + parts: [ + { + id: PartID.ascending(), + messageID: id, + sessionID: input.sessionID, + type: "text", + text, + }, + ], + } +} + +function writeState(input: unknown) { + return Effect.promise(async () => { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(state, JSON.stringify(input)) + }) +} + +function run(input: { agent: "pinned" | "worker"; state?: unknown; client?: string }) { + return provideTmpdirInstance( + () => + Effect.gen(function* () { + process.env.KILO_CLIENT = input.client ?? "cli" + if (input.state) yield* writeState(input.state) + + const { chat, assistant } = yield* seed(input.agent) + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (value) => (seen = value) }) + + const result = yield* def.execute( + { + description: `run ${input.agent}`, + prompt: "inspect resolution", + subagent_type: input.agent, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + return { + prompt: seen?.model, + variant: seen?.variant, + model: result.metadata.model, + metadataVariant: result.metadata.variant, + } + }), + { + config: { + agent: { + worker: { mode: "subagent" }, + pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, + }, + }, + }, + ) +} + +describe("tool.task model resolution", () => { + it.live("saved model beats agent config for pinned", () => + run({ + agent: "pinned", + state: { model: { pinned: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toEqual(savedVariant) + expect(result.model).toMatchObject({ ...saved, variant: savedVariant }) + expect(result.metadataVariant).toEqual(savedVariant) + }), + ), + ), + ) + + it.live("saved model beats parent for worker", () => + run({ + agent: "worker", + state: { model: { worker: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toEqual(savedVariant) + expect(result.model).toMatchObject({ ...saved, variant: savedVariant }) + expect(result.metadataVariant).toEqual(savedVariant) + }), + ), + ), + ) + + it.live("saved model without variant leaves variant undefined", () => + run({ + agent: "worker", + state: { model: { worker: saved } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(saved) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("unrelated saved variant key ignored", () => + run({ + agent: "worker", + state: { model: { worker: saved }, variant: { "other-provider/other-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(saved) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(saved) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("missing saved entry falls back to agent config for pinned", () => + run({ + agent: "pinned", + state: { model: { worker: saved } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(cfg) + expect(result.variant).toEqual(cfgVariant) + expect(result.model).toEqual(cfg) + expect(result.metadataVariant).toEqual(cfgVariant) + }), + ), + ), + ) + + it.live("no file and no agent config falls back to parent for worker", () => + run({ + agent: "worker", + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) + + it.live("malformed file ignored and falls back to agent config for pinned", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + process.env.KILO_CLIENT = "cli" + yield* Effect.promise(async () => { + await fs.mkdir(Global.Path.state, { recursive: true }) + await fs.writeFile(state, "{bad json") + }) + + const { chat, assistant } = yield* seed("pinned") + const tool = yield* TaskTool + const def = yield* tool.init() + let seen: SessionPrompt.PromptInput | undefined + const promptOps = stubOps({ onPrompt: (value) => (seen = value) }) + + const result = yield* def.execute( + { + description: "run pinned", + prompt: "inspect resolution", + subagent_type: "pinned", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps, bypassAgentCheck: true }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(seen?.model).toEqual(cfg) + expect(seen?.variant).toEqual(cfgVariant) + expect(result.metadata.model).toEqual(cfg) + expect(result.metadata.variant).toEqual(cfgVariant) + }), + { + config: { + agent: { + worker: { mode: "subagent" }, + pinned: { mode: "subagent", model: "config-provider/config-model", variant: cfgVariant }, + }, + }, + }, + ), + ) + + it.live("non-CLI client gate ignores saved worker model and uses parent", () => + run({ + agent: "worker", + client: "vscode", + state: { model: { worker: saved }, variant: { "saved-provider/saved-model": savedVariant } }, + }).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.prompt).toEqual(parent) + expect(result.variant).toBeUndefined() + expect(result.model).toEqual(parent) + expect(result.metadataVariant).toBeUndefined() + }), + ), + ), + ) +})