Merge branch 'main' into charmed-saver

This commit is contained in:
Kirill Kalishev
2026-04-24 16:19:06 -04:00
committed by GitHub
13 changed files with 855 additions and 69 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Show the open GitHub PR for the current branch in the session sidebar.
+5
View File
@@ -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.
+9
View File
@@ -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).
+49 -49
View File
@@ -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",
@@ -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.<name>.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.
@@ -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 | `<project>/.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)" %}
+1 -1
View File
@@ -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:",
@@ -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,
@@ -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<Pr> & { 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<void>((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<Pr | null> {
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<Pr>
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<string | null> {
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<string | null> {
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<Pr | null> {
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<Pr | null> {
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 <box> must be present unconditionally — the OpenTUI slot
// registry relies on a stable root node per plugin. Gating the whole tree
// on `<Show>` breaks the mount. Conditionally render only the inner text.
return (
<box>
<Show when={pr()}>
<text fg={theme().textMuted}>
PR #{pr()!.number} - {pr()!.title}
</text>
</Show>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 50,
slots: {
sidebar_content(_ctx, _props) {
return <View api={api} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin
@@ -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}`],
}
})
}
+13 -15
View File
@@ -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
+12 -4
View File
@@ -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)`,
@@ -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()
}),
),
),
)
})