mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge branch 'main' into charmed-saver
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": minor
|
||||
---
|
||||
|
||||
Show the open GitHub PR for the current branch in the session sidebar.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
Generated
+49
-49
@@ -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)" %}
|
||||
|
||||
|
||||
@@ -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}`],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user