Merge remote-tracking branch 'origin/main' into puddle-barometer

This commit is contained in:
kirillk
2026-08-03 09:04:57 -04:00
77 changed files with 2953 additions and 344 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix Agent Manager mode shortcuts in the New Worktree dialog so the selected mode and its matching model stay in sync.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show aggregate added and removed line counts for multi-file patch tool calls.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent configured compaction thresholds from interrupting active tool sequences.
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Show why a tool call was auto-approved or denied in the TUI, and record the denial reason on the tool call metadata (visible in `kilo export`) alongside the existing auto-approval reason.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Speed up local session recall searches across large conversation histories.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show and hide Agent Manager worktree hover cards instantly.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the prompt controls at a consistent height when the model selector shows the prompt-training indicator.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep Kilo's persona out of generated conversation titles and Agent Manager branch names.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Stop treating `` !`cmd` `` shown as an inline code example in skill documentation as a live command, so it no longer triggers a shell permission prompt.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix skill folder path and URL rows clipping and pushing the remove (×) button off-screen in narrow Skills settings panels. Long paths and URLs now truncate within their row, and hovering a truncated value shows the full path or URL in a tooltip.
+173 -48
View File
@@ -1,8 +1,10 @@
{
"version": "7",
"dialect": "sqlite",
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
"prevIds": ["abd2f920-b822-49af-b8a7-2e48367d424f"],
"id": "7a2d751a-b26c-4a89-9905-800e3f6a8b8a",
"prevIds": [
"169a0f0f-d58f-479f-b024-fa1c7b9a09db"
],
"ddl": [
{
"name": "workspace",
@@ -1511,9 +1513,13 @@
"table": "session_share"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1522,9 +1528,13 @@
"table": "workspace"
},
{
"columns": ["active_account_id"],
"columns": [
"active_account_id"
],
"tableTo": "account",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1533,9 +1543,13 @@
"table": "account_state"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"tableTo": "event_sequence",
"columnsTo": ["aggregate_id"],
"columnsTo": [
"aggregate_id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1544,9 +1558,13 @@
"table": "event"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1555,9 +1573,13 @@
"table": "permission"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1566,9 +1588,13 @@
"table": "project_directory"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1577,9 +1603,13 @@
"table": "message"
},
{
"columns": ["message_id"],
"columns": [
"message_id"
],
"tableTo": "message",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1588,9 +1618,13 @@
"table": "part"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1599,9 +1633,13 @@
"table": "session_context_epoch"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1610,9 +1648,13 @@
"table": "session_input"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1621,9 +1663,13 @@
"table": "session_message"
},
{
"columns": ["project_id"],
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1632,9 +1678,13 @@
"table": "session"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1643,9 +1693,13 @@
"table": "todo"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"tableTo": "session",
"columnsTo": ["id"],
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1654,133 +1708,174 @@
"table": "session_share"
},
{
"columns": ["email", "url"],
"columns": [
"email",
"url"
],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": ["project_id", "directory"],
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": ["session_id", "position"],
"columns": [
"session_id",
"position"
],
"nameExplicit": false,
"name": "todo_pk",
"entityType": "pks",
"table": "todo"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
"entityType": "pks"
},
{
"columns": ["name"],
"columns": [
"name"
],
"nameExplicit": false,
"name": "data_migration_pk",
"table": "data_migration",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": ["aggregate_id"],
"columns": [
"aggregate_id"
],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "message_pk",
"table": "message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "part_pk",
"table": "part",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_context_epoch_pk",
"table": "session_context_epoch",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_input_pk",
"table": "session_input",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": ["id"],
"columns": [
"id"
],
"nameExplicit": false,
"name": "session_pk",
"table": "session",
"entityType": "pks"
},
{
"columns": ["session_id"],
"columns": [
"session_id"
],
"nameExplicit": false,
"name": "session_share_pk",
"table": "session_share",
@@ -1902,6 +1997,36 @@
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
"value": "session_id",
"isExpression": false
},
{
"value": "id",
"isExpression": false
},
{
"value": "message_id",
"isExpression": false
},
{
"value": "json_extract(\"data\", '$.type')",
"isExpression": true
},
{
"value": "CASE WHEN json_extract(\"data\", '$.type') = 'text' THEN coalesce(json_extract(\"data\", '$.text'), '') WHEN json_extract(\"data\", '$.type') = 'file' THEN trim(coalesce(json_extract(\"data\", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract(\"data\", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(\"data\", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract(\"data\", '$.source.path'), '') || ' ' || coalesce(json_extract(\"data\", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract(\"data\", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(\"data\", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract(\"data\", '$.source.clientName'), '')) ELSE coalesce(json_extract(\"data\", '$.state.error'), '') END",
"isExpression": true
}
],
"isUnique": false,
"where": "(json_extract(\"part\".\"data\", '$.type') = 'text' AND coalesce(json_extract(\"part\".\"data\", '$.synthetic'), 0) = 0 AND coalesce(json_extract(\"part\".\"data\", '$.ignored'), 0) = 0) OR json_extract(\"part\".\"data\", '$.type') = 'file' OR (json_extract(\"part\".\"data\", '$.type') = 'tool' AND json_extract(\"part\".\"data\", '$.state.status') = 'error')",
"origin": "manual",
"name": "recall_part_search_idx",
"entityType": "indexes",
"table": "part"
},
{
"columns": [
{
@@ -2098,4 +2223,4 @@
}
],
"renames": []
}
}
+1
View File
@@ -245,6 +245,7 @@ export default {
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
yield* tx.run(`CREATE INDEX \`recall_part_search_idx\` ON \`part\` (\`session_id\`,\`id\`,\`message_id\`,json_extract("data", '$.type'),CASE WHEN json_extract("data", '$.type') = 'text' THEN coalesce(json_extract("data", '$.text'), '') WHEN json_extract("data", '$.type') = 'file' THEN trim(coalesce(json_extract("data", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.path'), '') || ' ' || coalesce(json_extract("data", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.clientName'), '')) ELSE coalesce(json_extract("data", '$.state.error'), '') END) WHERE json_valid("part"."data") AND ((json_extract("part"."data", '$.type') = 'text' AND coalesce(json_extract("part"."data", '$.synthetic'), 0) = 0 AND coalesce(json_extract("part"."data", '$.ignored'), 0) = 0) OR json_extract("part"."data", '$.type') = 'file' OR (json_extract("part"."data", '$.type') = 'tool' AND json_extract("part"."data", '$.state.status') = 'error'));`)
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
@@ -0,0 +1,25 @@
import { sql } from "drizzle-orm"
import { index, type AnySQLiteColumn } from "drizzle-orm/sqlite-core"
export namespace RecallPartIndex {
export const createSql = `CREATE INDEX IF NOT EXISTS \`recall_part_search_idx\` ON \`part\` (\`session_id\`,\`id\`,\`message_id\`,json_extract("data", '$.type'),CASE WHEN json_extract("data", '$.type') = 'text' THEN coalesce(json_extract("data", '$.text'), '') WHEN json_extract("data", '$.type') = 'file' THEN trim(coalesce(json_extract("data", '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.path'), '') || ' ' || coalesce(json_extract("data", '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract("data", '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract("data", '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract("data", '$.source.clientName'), '')) ELSE coalesce(json_extract("data", '$.state.error'), '') END) WHERE json_valid("part"."data") AND ((json_extract("part"."data", '$.type') = 'text' AND coalesce(json_extract("part"."data", '$.synthetic'), 0) = 0 AND coalesce(json_extract("part"."data", '$.ignored'), 0) = 0) OR json_extract("part"."data", '$.type') = 'file' OR (json_extract("part"."data", '$.type') = 'tool' AND json_extract("part"."data", '$.state.status') = 'error'));`
export function make(table: {
session_id: AnySQLiteColumn
id: AnySQLiteColumn
message_id: AnySQLiteColumn
data: AnySQLiteColumn
}) {
return index("recall_part_search_idx")
.on(
table.session_id,
table.id,
table.message_id,
sql`json_extract(${table.data}, '$.type')`,
sql`CASE WHEN json_extract(${table.data}, '$.type') = 'text' THEN coalesce(json_extract(${table.data}, '$.text'), '') WHEN json_extract(${table.data}, '$.type') = 'file' THEN trim(coalesce(json_extract(${table.data}, '$.filename'), '') || ' ' || CASE WHEN coalesce(json_extract(${table.data}, '$.url'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(${table.data}, '$.url'), '') ELSE '' END || ' ' || coalesce(json_extract(${table.data}, '$.source.path'), '') || ' ' || coalesce(json_extract(${table.data}, '$.source.name'), '') || ' ' || CASE WHEN coalesce(json_extract(${table.data}, '$.source.uri'), '') NOT LIKE 'data:%' THEN coalesce(json_extract(${table.data}, '$.source.uri'), '') ELSE '' END || ' ' || coalesce(json_extract(${table.data}, '$.source.clientName'), '')) ELSE coalesce(json_extract(${table.data}, '$.state.error'), '') END`,
)
.where(
sql`json_valid(${table.data}) AND ((json_extract(${table.data}, '$.type') = 'text' AND coalesce(json_extract(${table.data}, '$.synthetic'), 0) = 0 AND coalesce(json_extract(${table.data}, '$.ignored'), 0) = 0) OR json_extract(${table.data}, '$.type') = 'file' OR (json_extract(${table.data}, '$.type') = 'tool' AND json_extract(${table.data}, '$.state.status') = 'error'))`,
)
}
}
+19
View File
@@ -44,6 +44,24 @@ const Cost = Schema.Struct({
),
})
// kilocode_change start - models.dev reasoning_options (snatched from upstream
// v1.18.11, #36624): effort tiers, thinking toggles, and token budgets.
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
// kilocode_change end
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
@@ -51,6 +69,7 @@ export const Model = Schema.Struct({
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), // kilocode_change
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
interleaved: Schema.optional(
+2
View File
@@ -13,6 +13,7 @@ import { WorkspaceV2 } from "../workspace"
import { Timestamps } from "../database/schema.sql"
import type { SystemContext } from "../system-context/index"
import { AgentV2 } from "../agent"
import { RecallPartIndex } from "../kilocode/session/recall-part-index" // kilocode_change
type SessionMessageData = Omit<(typeof SessionMessage.Message)["Encoded"], "type" | "id">
type V1MessageData = Omit<SessionV1.Info, "id" | "sessionID">
@@ -93,6 +94,7 @@ export const PartTable = sqliteTable(
(table) => [
index("part_message_id_id_idx").on(table.message_id, table.id),
index("part_session_idx").on(table.session_id),
RecallPartIndex.make(table), // kilocode_change
],
)
@@ -58,6 +58,23 @@ The composer stays editable while the agent is working, so you don't have to wai
A queued message shows a subtle **Queued** badge on its bubble. The badge clears when the message starts processing or when the queue drains or is cancelled. Queueing works for Cloud Agent sessions and for remote sessions on a connected `kilo remote` CLI instance.
## Attachments in remote sessions
When you connect the mobile app to a `kilo remote` CLI session, you can share files in both directions.
### Sending files from your phone to the CLI
Attach up to **5 files** (each up to **20 MiB**) from your phone to the remote session. The CLI automatically processes them:
- **Text, images, and PDFs** — the file content is converted to a `data:` URL and handed directly to the model as a file part. The model sees the content as if you had loaded it locally.
- **Other file types** (binaries, archives, etc.) — the file is saved to a per-session scratch directory on the CLI machine. The session transcript shows the saved path, filename, file size, and MIME type. The agent can inspect the file with the `read` tool for text content or shell utilities for binary content.
Attaching files from the phone is the mobile flow — this is separate from `kilo run --file <path>`, which attaches local files to a local prompt.
### Receiving files from the CLI on your phone
While the CLI is connected, the agent can deliver a file to your phone with the `send_file` tool (up to **4 MiB**, remote sessions only). The file appears as a chip on the tool card — tap the chip to open the share sheet and save or forward the file. This tool works only when `kilo remote` is actively connected; it is not available in Cloud Agent sessions.
## Reviewing GitHub pull requests
Open a pull request from a PR link to review it without leaving the app:
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:50e25bc93fa7d963c9d3a6b71918e623b02ab42a3c638cc2936a02cd2a773e26
size 5261
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ac5b0f48f483ce5fff844aec9316effb5395ee43740b782ebaa9adab7807f59
size 5690
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b026135c2d4e3e4fa6b6fcb1057306f7d1d92e66ebf33949ab31ff0206f0ae04
size 35914
@@ -238,6 +238,18 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty
color: var(--text-weak);
}
[data-slot="message-part-tool-changes"] {
display: inline-flex;
align-items: baseline;
flex-shrink: 0;
}
[data-slot="basic-tool-tool-subtitle"] + [data-slot="message-part-tool-changes"]::before {
content: "·";
margin-right: 6px;
color: var(--text-weak);
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-subtitle"],
[data-slot="basic-tool-tool-arg"],
@@ -1844,12 +1844,12 @@ function ToolMetaLine(props: {
)
}
function ToolChanges(props: { changes: DiffValue; animate?: boolean }) {
function ToolChanges(props: { changes: DiffValue; animate?: boolean; slot?: string }) {
let ref: HTMLDivElement | undefined
useToolFade(() => ref, { delay: 0.04, animate: props.animate })
return (
<div ref={ref}>
<div ref={ref} data-slot={props.slot}>
<DiffChanges changes={props.changes} />
</div>
)
@@ -2764,7 +2764,16 @@ ToolRegistry.register({
/>
)}
</Show>
<Show when={!single() && subtitle()}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show>
<Show when={!single() && subtitle()}>
{(text) => (
<>
<ToolText text={text()} animate={reveal()} />
<Show when={files().some((file) => file.additions > 0 || file.deletions > 0)}>
<ToolChanges changes={files()} animate={reveal()} slot="message-part-tool-changes" />
</Show>
</>
)}
</Show>
</div>
</div>
</div>
@@ -0,0 +1,118 @@
import { expect, test, type Locator, type Page } from "@playwright/test"
const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern"
const STORY_ID = "settings--agent-behaviour-skills-overflow"
const SEEDED_PATH = "/home/user/projects/very-long-directory-name/skills-collection/team-shared"
const SEEDED_PATH_2 = "./relative/path/to/skills/another/very/long/nested/directory"
const SEEDED_URL = "https://example.com/very/long/path/to/skills/registry/index.json?ref=main&token=abc123"
const SEEDED_URL_2 = "https://other.example.org/skills/v2/registry.json?namespace=team&version=latest"
function overflowFixture(page: Page) {
return page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, {
waitUntil: "load",
})
}
function cardFor(loc: Locator) {
return loc.locator("xpath=following-sibling::div[@data-component='card'][1]")
}
async function assertRowContained(row: Locator, card: Locator, label: string) {
const rowBox = await row.boundingBox()
const cardBox = await card.boundingBox()
expect(rowBox, `${label}: row bounding box`).not.toBeNull()
expect(cardBox, `${label}: card bounding box`).not.toBeNull()
expect(rowBox!.width, `${label}: row width <= card width (no horizontal overflow)`).toBeLessThanOrEqual(
cardBox!.width + 1,
)
expect(rowBox!.x, `${label}: row left edge inside card`).toBeGreaterThanOrEqual(cardBox!.x - 1)
expect(rowBox!.x + rowBox!.width, `${label}: row right edge inside card`).toBeLessThanOrEqual(
cardBox!.x + cardBox!.width + 1,
)
}
test.describe("skills settings responsive layout", () => {
test("folder-path and URL rows stay contained and the × button remains visible at a narrow viewport", async ({
page,
}) => {
await page.setViewportSize({ width: 320, height: 720 })
await overflowFixture(page)
const pathsHeader = page.getByRole("heading", { name: "Skill Folder Paths" })
const urlsHeader = page.getByRole("heading", { name: "Skill URLs" })
await expect(pathsHeader).toBeVisible()
await expect(urlsHeader).toBeVisible()
const pathsCard = cardFor(pathsHeader)
const urlsCard = cardFor(urlsHeader)
await expect(pathsCard).toBeVisible()
await expect(urlsCard).toBeVisible()
for (const seeded of [SEEDED_PATH, SEEDED_PATH_2]) {
const span = page.getByText(seeded, { exact: true })
await expect(span, `path value visible: ${seeded}`).toBeVisible()
const trigger = span.locator("xpath=ancestor::div[@data-component='tooltip-trigger'][1]")
await expect(trigger, `path Tooltip trigger wraps the value: ${seeded}`).toBeVisible()
const row = trigger.locator("xpath=parent::div")
await assertRowContained(row, pathsCard, `Skill Folder Paths row "${seeded}"`)
const closeButton = row.locator('[data-icon="close"]')
await expect(closeButton, "× button is visible").toBeVisible()
const btnBox = await closeButton.boundingBox()
const cardBox = await pathsCard.boundingBox()
expect(btnBox, "× button bounding box").not.toBeNull()
expect(btnBox!.x + btnBox!.width, "× button right edge inside card (not pushed off-screen)").toBeLessThanOrEqual(
cardBox!.x + cardBox!.width + 1,
)
// The full path is always in the DOM — the ellipsis is visual-only, so
// screen readers read the complete value without any interaction. The
// Tooltip still adds a hover affordance for mouse users so the full
// path is visible without resizing.
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full path on hover: ${seeded}`).toBeVisible()
}
for (const seeded of [SEEDED_URL, SEEDED_URL_2]) {
const span = page.getByText(seeded, { exact: true })
await expect(span, `URL value visible: ${seeded}`).toBeVisible()
const trigger = span.locator("xpath=ancestor::div[@data-component='tooltip-trigger'][1]")
await expect(trigger, `URL Tooltip trigger wraps the value: ${seeded}`).toBeVisible()
const row = trigger.locator("xpath=parent::div")
await assertRowContained(row, urlsCard, `Skill URLs row "${seeded}"`)
const closeButton = row.locator('[data-icon="close"]')
await expect(closeButton, "× button is visible").toBeVisible()
const btnBox = await closeButton.boundingBox()
const cardBox = await urlsCard.boundingBox()
expect(btnBox, "× button bounding box").not.toBeNull()
expect(btnBox!.x + btnBox!.width, "× button right edge inside card (not pushed off-screen)").toBeLessThanOrEqual(
cardBox!.x + cardBox!.width + 1,
)
// The full URL is always in the DOM — the ellipsis is visual-only, so
// screen readers read the complete value without any interaction. The
// Tooltip still adds a hover affordance for mouse users so the full
// URL is visible without resizing.
await trigger.hover()
const content = page.locator('[data-component="tooltip"]').filter({ hasText: seeded })
await expect(content, `Kilo Tooltip exposes full URL on hover: ${seeded}`).toBeVisible()
}
for (const [label, card] of [
["Skill Folder Paths", pathsCard],
["Skill URLs", urlsCard],
] as const) {
const add = card.getByRole("button", { name: "Add", exact: true })
await expect(add, `Add button visible inside ${label} card`).toBeVisible()
const addBox = await add.boundingBox()
const cardBox = await card.boundingBox()
expect(addBox, "Add button bounding box").not.toBeNull()
expect(addBox!.x + addBox!.width, `Add button right edge inside ${label} card`).toBeLessThanOrEqual(
cardBox!.x + cardBox!.width + 1,
)
}
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test"
import { createModeRouter } from "../../webview-ui/agent-manager/mode-router"
describe("Agent Manager mode router", () => {
it("dispatches to the active modal handler and reports consumption", () => {
const router = createModeRouter()
const directions: number[] = []
router.register((direction) => directions.push(direction))
expect(router.dispatch(1)).toBe(true)
expect(router.dispatch(-1)).toBe(true)
expect(directions).toEqual([1, -1])
})
it("restores normal routing after the modal unregisters", () => {
const router = createModeRouter()
const dispose = router.register(() => undefined)
dispose()
expect(router.dispatch(1)).toBe(false)
})
it("does not let an old modal cleanup remove a replacement handler", () => {
const router = createModeRouter()
const first = router.register(() => undefined)
const directions: number[] = []
router.register((direction) => directions.push(direction))
first()
expect(router.dispatch(1)).toBe(true)
expect(directions).toEqual([1])
})
})
@@ -0,0 +1,36 @@
import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { markdownRenderedChildren } from "../../webview-ui/diff-viewer/markdown-rendered-children"
const window = new Window()
describe("markdownRenderedChildren", () => {
it("flattens the current renderer block wrapper", () => {
const root = window.document.createElement("div")
root.innerHTML = `
<div data-markdown-block>
<h1>Heading</h1>
<p>Paragraph</p>
<ul><li>Item</li></ul>
<table><tbody><tr><td>Cell</td></tr></tbody></table>
</div>
`
expect(markdownRenderedChildren(root).map((node) => node.tagName)).toEqual(["H1", "P", "UL", "TABLE"])
})
it("preserves legacy top-level blocks and ignores inserted annotations", () => {
const root = window.document.createElement("div")
root.innerHTML = `
<h1>Heading</h1>
<div class="am-markdown-inline-annotations"></div>
<p>Paragraph</p>
<div data-markdown-block>
<div class="am-markdown-inline-annotations"></div>
<blockquote>Quote</blockquote>
</div>
`
expect(markdownRenderedChildren(root).map((node) => node.tagName)).toEqual(["H1", "P", "BLOCKQUOTE"])
})
})
@@ -3,6 +3,7 @@ import {
type ModelStore,
type ResolveEnv,
applyModel,
getAgentModel,
getSessionModel,
getSelected,
} from "../../webview-ui/src/context/session-model-store"
@@ -149,6 +150,22 @@ describe("per-session model selection", () => {
})
describe("per-mode model memory", () => {
it("uses remembered model selections for modes without configured models", () => {
const store = { ...emptyStore(), modelSelections: { ask: gpt } }
expect(getAgentModel(store, env(), "ask")).toEqual(gpt)
})
it("ignores stale remembered selections when a configured mode model is user-set", () => {
const configured: ResolveEnv = {
...env(),
getModeModel: (name) => (name === "code" ? claude : null),
}
const store = { ...emptyStore(), modelSelections: { code: gpt } }
expect(getAgentModel(store, configured, "code", true)).toEqual(claude)
})
it("applyModel in a session writes only to sessionOverrides", () => {
const store = emptyStore()
const result = applyModel(store, "code", claude, "session-a")
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"
import {
cycleVariant,
getAgentVariant,
getVariant,
sessionVariantKeys,
sessionVariants,
@@ -43,6 +44,13 @@ describe("per-session variant selection", () => {
expect(getVariant(store, model, variants, "ask")).toBe("high")
})
it("resolves the effective variant for a mode and model", () => {
const store: Record<string, string> = {}
store[variantKey(model, "ask")] = "high"
expect(getAgentVariant(store, model, { variants: { low: {}, high: {} } }, "ask")).toBe("high")
})
it("carries the pre-submit agent variant into a newly created session", () => {
const store: Record<string, string> = {}
@@ -76,6 +76,7 @@ import { ProviderShell } from "../src/context/provider-shell"
import { ChatView } from "../src/components/chat"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { createModeRouter } from "./mode-router"
import { ProjectList } from "./ProjectList"
import { SidebarBody } from "./SidebarBody"
import { TabBar } from "./TabBar"
@@ -227,6 +228,7 @@ const AgentManagerContent: Component = () => {
const session = useSession()
const vscode = useVSCode()
const dialog = useDialog()
const mode = createModeRouter()
let sidebarSearchMenu: SidebarSearchMenuRef | undefined
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
@@ -1111,9 +1113,11 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "focusSearch")
focusChatSearch({ history: setHistory, review: setReviewActive, terminal: () => terms.setActiveId(undefined) })
else if (msg.action === "newTerminal") termHandlers.requestNew()
else if (msg.action === "cycleAgentMode" && document.hasFocus()) cycleAgent(1)
else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) cycleAgent(-1)
else {
else if (msg.action === "cycleAgentMode" && document.hasFocus()) {
if (!mode.dispatch(1)) cycleAgent(1)
} else if (msg.action === "cyclePreviousAgentMode" && document.hasFocus()) {
if (!mode.dispatch(-1)) cycleAgent(-1)
} else {
// Handle jumpTo1 through jumpTo9
const match = /^jumpTo([1-9])$/.exec(msg.action ?? "")
if (match) projectNav.jump(parseInt(match[1]!) - 1)
@@ -1790,7 +1794,9 @@ const AgentManagerContent: Component = () => {
const showNewWorktreeDialog = () => {
if (!loaded()) return
expandSidebar()
dialog.show(() => <NewWorktreeDialog onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />)
dialog.show(() => (
<NewWorktreeDialog mode={mode} onClose={() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />
))
}
const confirmDeleteWorktree = (worktreeId: string) => {
@@ -2256,6 +2262,7 @@ const AgentManagerContent: Component = () => {
selectedProject={activeProjectId()}
selection={selection() ?? undefined}
currentSessionID={session.currentSessionID}
mode={mode}
bindings={kb()}
t={t}
onSearchRef={(ref) => (sidebarSearchMenu = ref)}
@@ -44,6 +44,8 @@ import { insertSpacedText } from "../src/components/chat/prompt-input-utils"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
import { BranchSelect, BranchSelectPopover } from "../src/components/shared/BranchSelect"
import { tracker } from "./telemetry"
import { cycleAgent } from "../src/context/session-agent"
import type { ModeRouter } from "./mode-router"
type VersionCount = 1 | 2 | 3 | 4
const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4]
@@ -74,9 +76,12 @@ function sanitizeBranchName(name: string): string {
.join("/")
}
export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBranch?: string; projectId?: string }> = (
props,
) => {
export const NewWorktreeDialog: Component<{
onClose: () => void
defaultBaseBranch?: string
projectId?: string
mode: ModeRouter
}> = (props) => {
const { t } = useLanguage()
const vscode = useVSCode()
const server = useServer()
@@ -101,10 +106,12 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const cached = vscode.getState<Record<string, unknown>>()
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
const [versions, setVersions] = createSignal<VersionCount>(1)
const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(session.configModel())
const initialAgent = session.selectedAgent()
const initialModel = session.modelForAgent(initialAgent)
const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(initialModel)
const [compareMode, setCompareMode] = createSignal(false)
const [modelAllocations, setModelAllocations] = createSignal<ModelAllocations>(new Map())
const [agent, setAgent] = createSignal(session.selectedAgent())
const [agent, setAgent] = createSignal(initialAgent)
const [starting, setStarting] = createSignal(false)
const [enhancing, setEnhancing] = createSignal(false)
const [showAdvanced, setShowAdvanced] = createSignal(false)
@@ -113,7 +120,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const [baseBranchOpen, setBaseBranchOpen] = createSignal(false)
const [compareOpen, setCompareOpen] = createSignal(false)
const [highlightedIndex, setHighlightedIndex] = createSignal(0)
const [variant, setVariant] = createSignal<string | undefined>(session.currentVariant())
const [variant, setVariant] = createSignal<string | undefined>(session.variantForAgent(initialAgent, initialModel))
const [sandbox, setSandbox] = createSignal<boolean | undefined>()
const [sandboxDefault, setSandboxDefault] = createSignal<boolean | undefined>()
const [sandboxOverride, setSandboxOverride] = createSignal<boolean | undefined>()
@@ -133,6 +140,34 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
setEnhancing(false)
}
const selectAgent = (name: string) => {
setAgent(name)
const sel = session.modelForAgent(name)
setModel(sel)
setVariant(session.variantForAgent(name, sel))
}
const resetModel = () => {
const sel = session.configModelForAgent(agent())
setModel(sel)
setVariant(session.variantForAgent(agent(), sel))
}
const cycle = (direction: 1 | -1) => {
cycleAgent({
agents: session.agents(),
direction,
selected: () => agent(),
select: selectAgent,
})
}
createEffect(() => {
if (tab() !== "new") return
const dispose = props.mode.register(cycle)
onCleanup(dispose)
})
// Variant list for the currently selected model
const variants = createMemo(() => {
const sel = model()
@@ -153,7 +188,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
// True when the user has changed the model from the session/config default
const overridden = createMemo(() => {
const sel = model()
const cfg = session.configModel()
const cfg = session.configModelForAgent(agent())
if (!sel || !cfg) return false
return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID
})
@@ -583,7 +618,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<ModeSwitcherBase
agents={session.agents()}
value={agent()}
onSelect={setAgent}
onSelect={selectAgent}
portal={false}
deferDismiss
/>
@@ -611,7 +646,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<Button
variant="ghost"
size="small"
onClick={() => setModel(session.configModel())}
onClick={resetModel}
aria-label={t("prompt.action.resetModel")}
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
@@ -19,6 +19,7 @@ import type { SidebarSearchItem } from "./sidebar-search"
import { LOCAL } from "./navigate"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { ProjectBranchDialog } from "./ProjectBranchDialog"
import type { ModeRouter } from "./mode-router"
interface Props {
projects: AgentProjectSnapshot[]
@@ -30,6 +31,7 @@ interface Props {
selectedProject?: string
selection?: string
currentSessionID?: () => string | undefined
mode: ModeRouter
busy?: (id: string) => boolean
bindings: Record<string, string>
t: LanguageContextValue["t"]
@@ -131,6 +133,7 @@ export const ProjectList: Component<Props> = (props) => {
dialog.show(() => (
<NewWorktreeDialog
projectId={projectId}
mode={props.mode}
defaultBaseBranch={state?.defaultBaseBranch ?? props.local[projectId]?.branch}
onClose={() => dialog.close()}
/>
@@ -187,8 +187,8 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
<ContextMenu>
<HoverCard
class="am-worktree-hover-card"
openDelay={50}
closeDelay={50}
openDelay={0}
closeDelay={0}
placement="right-start"
gutter={8}
open={hovered() && !overClose() && !props.pendingDelete}
@@ -3283,11 +3283,11 @@ body.am-wt-dragging-active * {
/* HoverCard popover for worktree items */
.am-worktree-hover-card[data-expanded] {
animation-duration: 80ms;
animation: none;
}
.am-worktree-hover-card[data-closed] {
animation-duration: 60ms;
animation: none;
}
.am-hover-card {
@@ -0,0 +1,26 @@
export type ModeDirection = 1 | -1
export type ModeHandler = (direction: ModeDirection) => void
export interface ModeRouter {
register: (handler: ModeHandler) => () => void
dispatch: (direction: ModeDirection) => boolean
}
export function createModeRouter(): ModeRouter {
const state: { handler?: ModeHandler } = {}
return {
register(handler) {
state.handler = handler
return () => {
if (state.handler === handler) state.handler = undefined
}
},
dispatch(direction) {
const handler = state.handler
if (!handler) return false
handler(direction)
return true
},
}
}
@@ -3,6 +3,7 @@ import type { AnnotationSide, DiffLineAnnotation, SelectedLineRange } from "@pie
import { markdownCommentBlocks, type MarkdownRange } from "./markdown-comment-ranges"
import type { AnnotationMeta } from "./review-annotations"
import { annotationSelector, isAnnotationMutation } from "./markdown-annotation-mutation"
import { markdownRenderedChildren } from "./markdown-rendered-children"
type Insert = "after" | "list" | "table"
@@ -24,16 +25,6 @@ export interface MarkdownAnnotationLayerProps {
onLineNumberClick: ((event: { annotationSide: AnnotationSide; lineNumber: number }) => void) | undefined
}
function children(root: HTMLElement): HTMLElement[] {
return Array.from(root.children).filter((child): child is HTMLElement => {
if (!(child instanceof HTMLElement)) return false
if (child.classList.contains("am-markdown-inline-annotations")) return false
if (child.classList.contains("am-markdown-list-annotation")) return false
if (child.classList.contains("am-markdown-table-annotation")) return false
return true
})
}
function listItems(root: HTMLElement): HTMLElement[] {
return Array.from(root.children).filter((child): child is HTMLElement => {
if (!(child instanceof HTMLElement)) return false
@@ -49,7 +40,7 @@ function tableRows(root: HTMLElement): HTMLElement[] {
}
function anchors(root: HTMLElement, source: ReturnType<typeof markdownCommentBlocks>): Anchor[] {
const rendered = children(root)
const rendered = markdownRenderedChildren(root)
const result: Anchor[] = []
let index = 0
@@ -0,0 +1,18 @@
const inserted = new Set([
"am-markdown-inline-annotations",
"am-markdown-list-annotation",
"am-markdown-table-annotation",
])
function isInserted(node: Element): boolean {
return Array.from(node.classList).some((name) => inserted.has(name))
}
export function markdownRenderedChildren(root: HTMLElement): HTMLElement[] {
return Array.from(root.children).flatMap((child) => {
if (isInserted(child)) return []
const nodes = child.hasAttribute("data-markdown-block") ? Array.from(child.children) : [child]
return nodes.filter((node) => !isInserted(node)).map((node) => node as HTMLElement)
})
}
@@ -7,6 +7,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Switch } from "@kilocode/kilo-ui/switch"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useConfig } from "../../context/config"
import { useSession } from "../../context/session"
@@ -877,7 +878,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": skillPaths().length > 0 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<div style={{ flex: 1 }}>
<div style={{ flex: 1, "min-width": 0 }}>
<TextField
value={newSkillPath()}
placeholder="e.g. ./skills"
@@ -902,14 +903,20 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillPaths().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<span
style={{
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "var(--kilo-font-size-12)",
}}
>
{path}
</span>
<Tooltip value={path} class="settings-skills-row-trigger">
<span
style={{
width: "100%",
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "var(--kilo-font-size-12)",
overflow: "hidden",
"text-overflow": "ellipsis",
"white-space": "nowrap",
}}
>
{path}
</span>
</Tooltip>
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeSkillPath(index())} />
</div>
)}
@@ -928,7 +935,7 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": skillUrls().length > 0 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<div style={{ flex: 1 }}>
<div style={{ flex: 1, "min-width": 0 }}>
<TextField
value={newSkillUrl()}
placeholder="e.g. https://example.com/skills"
@@ -953,14 +960,20 @@ const AgentBehaviourTab: Component = () => {
"border-bottom": index() < skillUrls().length - 1 ? "1px solid var(--border-weak-base)" : "none",
}}
>
<span
style={{
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "var(--kilo-font-size-12)",
}}
>
{url}
</span>
<Tooltip value={url} class="settings-skills-row-trigger">
<span
style={{
width: "100%",
"font-family": "var(--vscode-editor-font-family, monospace)",
"font-size": "var(--kilo-font-size-12)",
overflow: "hidden",
"text-overflow": "ellipsis",
"white-space": "nowrap",
}}
>
{url}
</span>
</Tooltip>
<IconButton size="small" variant="ghost" icon="close" onClick={() => removeSkillUrl(index())} />
</div>
)}
@@ -78,6 +78,17 @@ export function getSelected(
return resolveModel(env, agentName, store.modelSelections[agentName], store.recentModels)
}
/** Returns the effective model for a mode outside a session scope. */
export function getAgentModel(
store: ModelStore,
env: ResolveEnv,
agentName: string,
userSet = false,
): ModelSelection | null {
const override = env.getModeModel(agentName) && userSet ? null : store.modelSelections[agentName]
return resolveModel(env, agentName, override, store.recentModels)
}
export interface ApplyResult {
modelSelections: Record<string, ModelSelection | null>
sessionOverrides: Record<string, ModelSelection>
@@ -24,6 +24,16 @@ export function getVariant(
return stored && variants.includes(stored) ? stored : variants[0]
}
export function getAgentVariant(
store: Record<string, string>,
sel: ModelSelection,
model: { variants?: Record<string, unknown> } | undefined,
agent: string,
) {
if (!model?.variants) return undefined
return getVariant(store, sel, Object.keys(model.variants), agent)
}
/**
* Next variant in the list, wrapping back to the first after the last.
* An unknown or missing current value starts at the first variant.
@@ -67,12 +67,13 @@ import {
} from "./session-utils"
import { Identifier } from "../utils/id"
import { resolveModelSelection } from "./model-selection"
import { getAgentModel } from "./session-model-store"
import { resolveMessagePrefs } from "./session-preferences"
import { errorIDs } from "./session-errors"
import { PartStash } from "./part-stash"
import { mergeParts, sameParts } from "./session-parts"
import { state as todoState } from "./todo-revert"
import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { getAgentVariant, getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "../../../src/shared/provider-model"
import { reviewMetadata, type ReviewMessageData } from "../../../src/shared/review-comments"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
@@ -201,6 +202,8 @@ interface SessionContextValue {
// Model selection (global, extension-lifetime)
selected: (sessionID?: string) => ModelSelection | null
configModel: (sessionID?: string) => ModelSelection | null
modelForAgent: (agent: string) => ModelSelection | null
configModelForAgent: (agent: string) => ModelSelection | null
selectModel: (providerID: string, modelID: string, sessionID?: string) => void
hasModelOverride: (sessionID?: string) => boolean
clearModelOverride: (sessionID?: string) => void
@@ -237,6 +240,7 @@ interface SessionContextValue {
// Thinking variant for the selected model
variantList: (sessionID?: string) => string[]
currentVariant: (sessionID?: string) => string | undefined
variantForAgent: (agent: string, model: ModelSelection | null) => string | undefined
selectVariant: (value: string, sessionID?: string) => void
// Model favorites
@@ -735,6 +739,30 @@ export const SessionProvider: ParentComponent = (props) => {
return resolveModel(agentName)
}
function modelForAgent(agentName: string): ModelSelection | null {
return getAgentModel(
{
modelSelections: store.modelSelections,
sessionOverrides: store.sessionOverrides,
agentSelections: store.agentSelections,
recentModels: store.recentModels,
},
{
providers: provider.providers(),
connected: provider.connected(),
getModeModel,
getGlobalModel,
fallback: KILO_AUTO,
},
agentName,
userSetAgents()[agentName] === true,
)
}
function configModelForAgent(agentName: string): ModelSelection | null {
return resolveModel(agentName)
}
/** True when the active model differs from what the config dictates. */
function hasModelOverride(sessionID?: string) {
const sel = selected(sessionID)
@@ -878,6 +906,12 @@ export const SessionProvider: ParentComponent = (props) => {
return Object.keys(model.variants)
}
function variantForAgent(agentName: string, sel: ModelSelection | null) {
if (!sel) return undefined
const model = provider.findModel(sel)
return getAgentVariant(store.variantSelections, sel, model, agentName)
}
const currentVariant = (sessionID?: string) => {
const sid = sessionID ?? currentSessionID()
const sel = selected(sid)
@@ -2947,6 +2981,8 @@ export const SessionProvider: ParentComponent = (props) => {
scopedSuggestions,
selected,
configModel,
modelForAgent,
configModelForAgent,
selectModel,
hasModelOverride,
clearModelOverride,
@@ -2993,6 +3029,7 @@ export const SessionProvider: ParentComponent = (props) => {
toggleFavorite,
variantList,
currentVariant,
variantForAgent,
selectVariant,
revert,
revertedCount,
@@ -97,14 +97,20 @@ const MOCK_PROVIDERS = {
const MOCK_MODELS = flattenModels(MOCK_PROVIDERS as any)
/** A synchronous mock ProviderContext — provides models without waiting for a postMessage round-trip. */
const MockProviderProvider: ParentComponent<{ kiloAuth?: boolean }> = (props) => {
const MockProviderProvider: ParentComponent<{ kiloAuth?: boolean; training?: boolean }> = (props) => {
const models = createMemo(() =>
MOCK_MODELS.map((model) => ({
...model,
mayTrainOnYourPrompts: props.training === true,
})),
)
const value = {
providers: () => MOCK_PROVIDERS as any,
connected: () => ["kilo"],
defaults: () => ({}),
defaultSelection: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
models: () => MOCK_MODELS,
findModel: (sel: any) => _findModel(MOCK_MODELS, sel),
models,
findModel: (sel: any) => _findModel(models(), sel),
authMethods: () => ({}),
authStates: () => (props.kiloAuth ? { kilo: "oauth" } : {}) as Record<string, ProviderAuthState>,
isModelValid: () => true,
@@ -221,6 +227,8 @@ export function mockSessionValue(overrides?: {
scopedQuestions: (sid?: string) => (sid ? qs.filter((q) => q.sessionID === sid) : qs),
scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions),
selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
modelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
configModelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }),
selectModel: noop,
hasModelOverride: () => false,
clearModelOverride: noop,
@@ -249,6 +257,7 @@ export function mockSessionValue(overrides?: {
toggleFavorite: noop,
variantList: () => [],
currentVariant: () => undefined,
variantForAgent: () => undefined,
selectVariant: noop,
sendMessage: noop,
sendCommand: noop,
@@ -300,6 +309,7 @@ interface StoryProvidersProps {
onOpenDiff?: OpenDiffFn
onOpenFile?: OpenFileFn
kiloAuth?: boolean
training?: boolean
/** When true, renders children without the default 12px padding wrapper */
noPadding?: boolean
}
@@ -430,7 +440,7 @@ export const StoryProviders: ParentComponent<StoryProvidersProps> = (props) => {
onProjectConfigChange={props.onProjectConfigChange}
>
<DisplayProvider>
<MockProviderProvider kiloAuth={props.kiloAuth}>
<MockProviderProvider kiloAuth={props.kiloAuth} training={props.training}>
<DialogProvider>
<LanguageContext.Provider
value={{
@@ -29,6 +29,7 @@ import { ThinkingSelectorBase } from "../components/shared/ThinkingSelector"
import { createSignal, onCleanup, onMount, type JSX } from "solid-js"
import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages"
import type { ReviewComment } from "../../diff-viewer/review-comments"
import { createModeRouter } from "../../agent-manager/mode-router"
import "../../agent-manager/agent-manager.css"
import "../../agent-manager/agent-manager-review.css"
@@ -1292,6 +1293,7 @@ export const MultiProjectSidebar: Story = {
<StoryProviders noPadding>
<div style={{ display: "flex", "flex-direction": "column", "max-height": "720px", overflow: "auto" }}>
<ProjectList
mode={createModeRouter()}
projects={[projectA, projectB]}
states={{
[projectA.id]: projectState(
@@ -29,7 +29,9 @@ const agents = [
const noop = () => {}
const PromptProviders: ParentComponent<{ variants?: boolean; modelOverride?: boolean }> = (props) => {
const PromptProviders: ParentComponent<{ variants?: boolean; modelOverride?: boolean; training?: boolean }> = (
props,
) => {
const base = mockSessionValue({ status: "idle" })
const session = {
...base,
@@ -42,7 +44,7 @@ const PromptProviders: ParentComponent<{ variants?: boolean; modelOverride?: boo
}
return (
<StoryProviders noPadding>
<StoryProviders noPadding training={props.training}>
{/* overflow:hidden prevents margin-collapse so top/bottom borders are captured in screenshots */}
<div style={{ overflow: "hidden" }}>
<SessionContext.Provider value={session as any}>{props.children}</SessionContext.Provider>
@@ -84,6 +86,28 @@ export const Default200: Story = {
),
}
// ---------------------------------------------------------------------------
// Stories — model whose prompts may be used for training
// ---------------------------------------------------------------------------
export const WithPromptTraining420: Story = {
name: "With prompt training indicator — 420px",
render: () => (
<PromptProviders training>
<PromptInput />
</PromptProviders>
),
}
export const WithPromptTraining200: Story = {
name: "With prompt training indicator — 200px",
render: () => (
<PromptProviders training>
<PromptInput />
</PromptProviders>
),
}
export const SandboxTooltipEnabled: Story = {
name: "Sandbox tooltip — enabled",
render: () => (
@@ -380,6 +380,51 @@ export const AgentBehaviourWorkflowsEmpty: Story = {
},
}
/** Skills subtab with seeded long paths/URLs in a narrow container — regression fixture for
* the responsive overflow bug where value cells retained min-content width and pushed the
* remove (×) IconButton off-screen. */
export const AgentBehaviourSkillsOverflow: Story = {
name: "AgentBehaviourTab — skills subtab narrow overflow",
render: () => {
const session = {
...mockSessionValue({ id: "skills-overflow-story", status: "idle" }),
agents: () => MOCK_AGENTS,
allAgents: () => MOCK_AGENTS,
removeAgent: noop,
removeMcp: noop,
skills: () => [],
refreshSkills: noop,
removeSkill: noop,
}
return (
<StoryProviders
sessionID="skills-overflow-story"
status="idle"
config={
{
skills: {
paths: [
"/home/user/projects/very-long-directory-name/skills-collection/team-shared",
"./relative/path/to/skills/another/very/long/nested/directory",
],
urls: [
"https://example.com/very/long/path/to/skills/registry/index.json?ref=main&token=abc123",
"https://other.example.org/skills/v2/registry.json?namespace=team&version=latest",
],
},
} as any
}
>
<SessionContext.Provider value={session as any}>
<div style={{ width: "320px", height: "700px", overflow: "auto" }}>
<SubtabWrapper tab="skills" />
</div>
</SessionContext.Provider>
</StoryProviders>
)
},
}
export const McpEditViewLocal: Story = {
name: "McpEditView — local server (stdio)",
render: () => (
@@ -497,6 +497,11 @@
flex-shrink: 0;
}
.model-selector-trigger-free-data [data-component="icon"] {
width: 14px;
height: 14px;
}
.model-selector-free-data {
display: inline-flex;
align-items: center;
@@ -139,3 +139,10 @@
text-align: right;
}
}
/* Skill Folder Paths / Skill URLs row: tooltip trigger that grows in the
flex row and lets the inner span truncate. Applied via Tooltip class. */
.settings-skills-row-trigger {
flex: 1;
min-width: 0;
}
@@ -98,4 +98,35 @@ export namespace PermissionProvenance {
rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action },
}
}
/**
* Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries.
*
* `DeniedError.ruleset` is untyped (`Schema.Any`). `Permission.ask`'s main deny path sets it to
* the exact rule `resolve()` matched against the request's pattern (via `Wildcard.match`), not
* merely the deny-permission subset — two deny rules for different patterns under the same
* permission (e.g. `bash: { "git push *": deny, "rm -rf *": deny }`) would otherwise be
* indistinguishable by permission alone, misattributing the denial to whichever rule happens to
* sort last.
*
* Other denial paths (hard Ask/Plan/Architect vetoes, headless-subagent policy) don't carry a
* specific rule, so `ruleset` there is still just the permission subset (or absent). Synthesize
* an explicit `deny` rule for the request's permission/pattern in that case: falling through to
* `classify({ rule: undefined })` would report the exact same `{ source: "default" }` shape the
* *approval* fallback uses for "no rule matched," rendering a refusal as an auto-approval.
*/
export function classifyDenial(input: {
ruleset: unknown
permission: string
patterns: readonly string[]
agent: string
origins: Origins
}): Approval {
const candidate = input.ruleset as Partial<Permission.Rule> | undefined
const rule =
candidate?.action === "deny" && typeof candidate.pattern === "string"
? (candidate as Permission.Rule)
: { permission: input.permission, pattern: input.patterns[0] ?? "*", action: "deny" as const }
return classify({ rule, agent: input.agent, origins: input.origins })
}
}
@@ -66,13 +66,13 @@ export namespace RemoteAttachments {
sql: "text/plain",
}
export const BINARY_MIME = "application/octet-stream"
// Hard cap on attachment bytes (5 MB + 1 byte so the helper aborts
// Hard cap on attachment bytes (20 MB + 1 byte so the helper aborts
// strictly when the body exceeds the agreed ceiling).
export const MAX_BYTES = 5 * 1024 * 1024 + 1
export const MAX_BYTES = 20 * 1024 * 1024 + 1
// Per-attachment fetch budget. R2 presigned GETs in the same region
// complete in tens of ms; 15s is generous but bounded so a stalled
// connection can never hold the prompt open indefinitely.
export const FETCH_TIMEOUT_MS = 15_000
// complete quickly, but a 20 MB body may take a few seconds on slower
// mobile connections, so the budget is generous.
export const FETCH_TIMEOUT_MS = 60_000
export const SCRATCH_DIRNAME = "remote-attachments"
export type Fetcher = (input: string, init?: RequestInit) => Promise<Response>
@@ -194,7 +194,7 @@ export namespace RemoteAttachments {
* - HTTPS only
* - redirects rejected
* - no credentials forwarded
* - body bounded to 5 MB + 1 byte
* - body bounded to 20 MB + 1 byte
* - bounded timeout
* - non-2xx rejected
*/
@@ -338,7 +338,7 @@ export namespace RemoteAttachments {
type: "text" as const,
text:
`attachment saved to ${target} (filename: ${filename ?? basename}, mime: ${BINARY_MIME}, size: ${bytes.byteLength} bytes). ` +
`Use the read tool on that path to inspect it.`,
`Inspect it with the read tool (text content) or shell utilities (binary content).`,
})
continue
}
@@ -2,6 +2,7 @@ import path from "path"
import { eq, inArray, sql } from "drizzle-orm"
import { Effect } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { RecallPartIndex } from "@opencode-ai/core/kilocode/session/recall-part-index"
import type { MessageV2 } from "@/session/message-v2"
import { SessionTable } from "@opencode-ai/core/session/sql"
import type { MessageID, PartID, SessionID } from "@/session/schema"
@@ -11,26 +12,17 @@ import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
export namespace RecallSearch {
const BATCH = 128
const BATCH = 8_192
const PAGE_SIZE = 1_024
const SCAN_SIZE = 16_384
const MAX_QUERY = 256
const MAX_TERMS = 12
const MAX_SNIPPETS = 3
const SNIPPET_CHARS = 360
const SNIPPET_CONTEXT = 120
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
const ready = new WeakSet<object>()
const FIELDS_SQL = `
p.rowid AS rowid,
p.id AS partID,
p.session_id AS sessionID,
CASE
WHEN json_extract(p.data, '$.type') = 'text' THEN json_extract(m.data, '$.role')
WHEN json_extract(p.data, '$.type') = 'file' THEN 'reference'
ELSE 'error'
END AS source,
CASE
const TEXT_SQL = `CASE
WHEN json_extract(p.data, '$.type') = 'text' THEN coalesce(json_extract(p.data, '$.text'), '')
WHEN json_extract(p.data, '$.type') = 'file' THEN trim(
coalesce(json_extract(p.data, '$.filename'), '') || ' ' ||
@@ -43,74 +35,65 @@ export namespace RecallSearch {
coalesce(json_extract(p.data, '$.source.clientName'), '')
)
ELSE coalesce(json_extract(p.data, '$.state.error'), '')
END AS text`
END`
const FILTER_SQL = `
(json_extract(p.data, '$.type') = 'text'
AND json_extract(m.data, '$.role') IN ('user', 'assistant')
AND coalesce(json_extract(p.data, '$.synthetic'), 0) = 0
AND coalesce(json_extract(p.data, '$.ignored'), 0) = 0)
OR json_extract(p.data, '$.type') = 'file'
OR (json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.state.status') = 'error')`
const PART_FILTER_SQL = `
json_valid(p.data) AND (
(json_extract(p.data, '$.type') = 'text'
AND coalesce(json_extract(p.data, '$.synthetic'), 0) = 0
AND coalesce(json_extract(p.data, '$.ignored'), 0) = 0)
OR json_extract(p.data, '$.type') = 'file'
OR (json_extract(p.data, '$.type') = 'tool'
AND json_extract(p.data, '$.state.status') = 'error')
)`
const pageSql = (
export const query = (
ids: SessionID[],
cursor: { sessionID: SessionID | ""; rowid: number },
rowid: number,
partID: string,
sessionID: SessionID | "",
messageID: MessageID | "",
terms: string[],
cursor: { sessionID: SessionID | ""; partID: PartID | "" },
) => sql`
WITH page AS (
SELECT p.rowid, p.id, p.message_id, p.session_id, p.data
FROM part AS p INDEXED BY part_session_idx
WHERE p.session_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})
AND (p.session_id > ${cursor.sessionID} OR (p.session_id = ${cursor.sessionID} AND p.rowid > ${cursor.rowid}))
AND p.rowid <= ${rowid}
AND p.id <= ${partID}
ORDER BY p.session_id, p.rowid
LIMIT ${SCAN_SIZE}
), found AS (
SELECT ${sql.raw(FIELDS_SQL)}
FROM page AS p
JOIN message AS m ON m.id = p.message_id
AND m.session_id = p.session_id
WHERE NOT (
m.session_id = ${sessionID} AND (
(json_extract(m.data, '$.role') = 'user' AND m.id >= ${messageID})
OR (json_extract(m.data, '$.role') = 'assistant' AND json_extract(m.data, '$.parentID') >= ${messageID})
)
)
AND (${sql.raw(FILTER_SQL)})
ORDER BY p.session_id, p.rowid
LIMIT ${PAGE_SIZE}
), next AS (
SELECT
CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE}
THEN (SELECT sessionID FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1)
ELSE (SELECT session_id FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1)
END AS sessionID,
CASE WHEN (SELECT count(*) FROM found) = ${PAGE_SIZE}
THEN (SELECT rowid FROM found ORDER BY sessionID DESC, rowid DESC LIMIT 1)
ELSE (SELECT rowid FROM page ORDER BY session_id DESC, rowid DESC LIMIT 1)
END AS rowid
), meta AS (
SELECT next.sessionID, next.rowid, count(*) AS parts
FROM next
JOIN page AS p ON p.session_id < next.sessionID OR (p.session_id = next.sessionID AND p.rowid <= next.rowid)
WHERE next.sessionID IS NOT NULL
GROUP BY next.sessionID, next.rowid
)
SELECT rowid, partID, sessionID, source, text, 0 AS meta, 0 AS parts
FROM found
UNION ALL
SELECT rowid, NULL AS partID, sessionID, NULL AS source, NULL AS text, 1 AS meta, parts
FROM meta
ORDER BY meta, sessionID, rowid`
SELECT
p.id AS partID,
p.message_id AS messageID,
p.session_id AS sessionID,
json_extract(p.data, '$.type') AS kind,
${sql.raw(TEXT_SQL)} AS text
FROM part AS p
WHERE p.session_id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})
AND (p.session_id > ${cursor.sessionID} OR (p.session_id = ${cursor.sessionID} AND p.id > ${cursor.partID}))
AND (${sql.raw(PART_FILTER_SQL)})
AND (
${sql.join(
terms.map((term) => sql`instr(lower(${sql.raw(TEXT_SQL)}), ${term}) > 0`),
sql` OR `,
)}
OR ${sql.raw(TEXT_SQL)} GLOB ('*[^' || char(1) || '-' || char(127) || ']*')
)
ORDER BY p.session_id, p.id
LIMIT ${PAGE_SIZE}`
const ensure = (db: Database.Interface["db"]) =>
Effect.gen(function* () {
if (ready.has(db)) return
yield* db.run(sql.raw(RecallPartIndex.createSql)).pipe(
Effect.tap(() => Effect.sync(() => ready.add(db))),
Effect.catch((error) => Effect.logWarning("recall index unavailable", { error })),
)
})
const messageSql = (ids: MessageID[]) => sql`
SELECT
id,
json_extract(data, '$.role') AS role,
coalesce(json_extract(data, '$.parentID'), '') AS parentID
FROM message
WHERE id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`,
)})`
export type Source = "user" | "assistant" | "reference" | "error"
@@ -131,7 +114,7 @@ export namespace RecallSearch {
export type Output = {
results: Result[]
sessions: number
parts: number
candidates: number
}
type Candidate = Match & {
@@ -149,19 +132,21 @@ export namespace RecallSearch {
type Row = {
partID: PartID
messageID: MessageID
sessionID: SessionID
source: Source
kind: "text" | "file" | "tool"
text: string
}
type PageRow = {
rowid: number
partID: PartID | null
sessionID: SessionID
source: Source | null
text: string | null
meta: number
parts: number
type Hit = Row & {
mask: number
phrase: boolean
}
type MessageRow = {
id: MessageID
role: "user" | "assistant"
parentID: MessageID | ""
}
export const search = Effect.fn("RecallSearch.search")(function* (input: {
@@ -180,7 +165,7 @@ export namespace RecallSearch {
}
const roots = [...new Set(input.directories.map(Filesystem.resolve))]
if (roots.length === 0) return { results: [], sessions: 0, parts: 0 }
if (roots.length === 0) return { results: [], sessions: 0, candidates: 0 }
yield* abort(input.signal)
const { db } = yield* Database.Service
@@ -217,37 +202,28 @@ export namespace RecallSearch {
})
}
yield* abort(input.signal)
if (items.size === 0) return { results: [], sessions: 0, parts: 0 }
if (items.size === 0) return { results: [], sessions: 0, candidates: 0 }
yield* ensure(db)
const ids = [...items.keys()]
const rowid =
(yield* db.get<{ rowid: number | null }>(sql`SELECT max(rowid) AS rowid FROM part`).pipe(Effect.orDie))?.rowid ??
0
const partID =
(yield* db.get<{ id: string | null }>(sql`SELECT max(id) AS id FROM part`).pipe(Effect.orDie))?.id ?? ""
const ids = [...items.keys()].sort()
const excludeSessionID = input.excludeSessionID ?? ""
const excludeFromMessageID = input.excludeFromMessageID ?? ""
let parts = 0
let candidates = 0
const consume = (row: Row) => {
const consume = (row: Hit, source: Source) => {
const item = items.get(row.sessionID)
if (!item || !row.text) return
if (!item) return
const normalized = fold(row.text)
const matched = mask(normalized, parsed.terms)
if (matched === 0) return
item.mask |= matched
item.sourceMask[row.source] |= matched
const phrase = normalized.includes(parsed.phrase)
item.phrase = Math.max(item.phrase, phrase ? weight(row.source) : 0)
item.mask |= row.mask
item.sourceMask[source] |= row.mask
item.phrase = Math.max(item.phrase, row.phrase ? weight(source) : 0)
candidate(
item.candidates,
{
source: row.source,
source,
partID: row.partID,
mask: matched,
phrase,
mask: row.mask,
phrase: row.phrase,
},
() => excerpt(row.text, parsed),
)
@@ -256,21 +232,48 @@ export namespace RecallSearch {
for (let index = 0; index < ids.length; index += BATCH) {
yield* abort(input.signal)
const batch = ids.slice(index, index + BATCH)
let cursor = { sessionID: "" as SessionID | "", rowid: 0 }
while (cursor.rowid <= rowid) {
const found = yield* db
.all<PageRow>(pageSql(batch, cursor, rowid, partID, excludeSessionID, excludeFromMessageID))
.pipe(Effect.orDie)
let cursor = { sessionID: "" as SessionID | "", partID: "" as PartID | "" }
while (true) {
const live = cursor.sessionID ? batch.filter((id) => id >= cursor.sessionID) : batch
if (live.length === 0) break
const found = yield* db.all<Row>(query(live, parsed.terms, cursor)).pipe(Effect.orDie)
if (found.length === 0) break
const last = found.at(-1)!
cursor = { sessionID: last.sessionID, rowid: last.rowid }
parts += last.parts
candidates += found.length
const hits: Hit[] = []
for (const row of found) {
if (row.meta || !row.partID || !row.source) continue
consume({ partID: row.partID, sessionID: row.sessionID, source: row.source, text: row.text ?? "" })
if (!row.text) continue
const normalized = fold(row.text)
const matched = mask(normalized, parsed.terms)
if (matched === 0) continue
hits.push({ ...row, mask: matched, phrase: normalized.includes(parsed.phrase) })
}
const messages = new Map<MessageID, MessageRow>()
const messageIDs = [...new Set(hits.map((row) => row.messageID))]
for (let offset = 0; offset < messageIDs.length; offset += BATCH) {
const rows = yield* db
.all<MessageRow>(messageSql(messageIDs.slice(offset, offset + BATCH)))
.pipe(Effect.orDie)
for (const row of rows) messages.set(row.id, row)
}
for (const row of hits) {
const message = messages.get(row.messageID)
if (!message) continue
if (row.sessionID === excludeSessionID) {
if (message.role === "user" && message.id >= excludeFromMessageID) continue
if (message.role === "assistant" && message.parentID >= excludeFromMessageID) continue
}
if (row.kind === "text") {
if (message.role !== "user" && message.role !== "assistant") continue
consume(row, message.role)
continue
}
consume(row, row.kind === "file" ? "reference" : "error")
}
const last = found.at(-1)!
cursor = { sessionID: last.sessionID, partID: last.partID }
yield* pause
yield* abort(input.signal)
if (found.length < PAGE_SIZE) break
}
}
yield* pause
@@ -292,7 +295,7 @@ export namespace RecallSearch {
item,
),
sessions: items.size,
parts,
candidates,
}
})
+114 -79
View File
@@ -4,32 +4,13 @@ import { Process } from "@/util/process"
import { SKILL_SHELL_DISABLED, SKILL_SHELL_UNTRUSTED } from "@/kilocode/skills/display"
import type * as Tool from "@/tool/tool"
// Shell injection for skill bodies mirrors Claude's "dynamic context injection":
// a `!`cmd`` placeholder in SKILL.md is replaced by the command's stdout before
// the content reaches the model. Unlike the slash-command path, this runs for
// model-initiated skill loads, so it is gated on three independent controls:
//
// 1. Trust: only skills from trusted sources (global ~/.claude, ~/.agents,
// KILO_CONFIG_DIR, and builtins) may execute. Untrusted project/downloaded
// skills never spawn a process.
// 2. Kill-switch: `disabled` (KILO_DISABLE_SKILL_SHELL) turns injection off
// entirely, matching Claude's disableSkillShellExecution.
// 3. Batch approval: every command in the file is decomposed with the same
// tree-sitter scan the bash tool uses (per sub-command patterns plus any
// out-of-project directories), then presented once, up front, as a single
// bash permission prompt naming every command — plus a separate, preceding
// external_directory prompt if any command touches a directory outside the
// project. The `skillShell` marker forces both prompts regardless of any
// allow/auto-approve rule; a deny rule or plan-mode veto on any sub-command
// still blocks. Approving both runs the batch; rejecting either aborts the load.
//
// Trust and the kill-switch also gate the slash-command path (`/skill`, session/prompt.ts),
// which is user-initiated. Batch approval (control 3) is specific to this model-initiated
// tool path — the slash-command path is not prompted because the user invoked it directly.
//
// Substitution runs exactly once. Command output is inlined as plain text and is
// never re-scanned, so a command cannot emit a `!`cmd`` placeholder that a later
// pass would execute (second-order injection).
// A `!`cmd`` placeholder in SKILL.md is replaced by the command's stdout before the
// content reaches the model. Runs only for model-initiated skill loads (not the
// user-initiated `/skill` path), gated by trust (only global/builtin skills), a
// kill-switch (KILO_DISABLE_SKILL_SHELL), and one batch bash ask naming every command
// up front (`skillShell` forces the ask past allow/auto-approve rules; a preceding
// external_directory ask covers out-of-project paths). Substitution runs once; output
// is never re-scanned, so a command can't emit a placeholder a later pass would run.
// Execution bounds: model-initiated commands must not hang the load, blow up
// context, or overrun the batch.
@@ -58,32 +39,24 @@ export namespace SkillInject {
}
export const render = Effect.fn("SkillInject.render")(function* (opts: Options) {
// Placeholders inside fenced code blocks are documentation examples, not live commands.
const fenced = fences(opts.content)
const live = ConfigMarkdown.shell(opts.content).filter((m) => !fenced(m.index))
// Fenced blocks and inline code spans (`` !`cmd` ``) are documentation, not live commands.
const inert = ranges(opts.content)
const live = ConfigMarkdown.shell(opts.content).filter((m) => !inert(m.index))
if (live.length === 0) return opts.content
// Defense-in-depth ordering: policy checks first, approval gate last. `replace` only
// rewrites live (unfenced) placeholders; fenced ones stay as literal text.
const replace = (value: (command: string) => string) => rewrite(opts.content, fenced, value)
// Policy checks before the approval gate; `replace` only touches live placeholders.
const replace = (value: (command: string) => string) => rewrite(opts.content, inert, value)
if (opts.disabled) return replace(() => SKILL_SHELL_DISABLED)
if (!opts.trusted) return replace(() => SKILL_SHELL_UNTRUSTED)
// `shell` is resolved by the caller via Shell.acceptable(cfg.shell), which
// rejects shells the tree-sitter bash scanner can't parse (fish/nu), keeping
// the parse used for the permission decision aligned with execution.
const shell = opts.shell
// Deduplicate identical commands, then cap the batch so a skill can't queue
// an unbounded number of processes.
// Dedupe, then cap so a skill can't queue an unbounded number of processes.
const commands = Array.from(new Set(live.map(([, cmd]) => cmd))).slice(0, MAX_COMMANDS)
// Decompose each command into sub-command patterns + out-of-project dir globs
// via the shared bash scan, so plan-mode denies and external_directory checks
// apply per sub-command instead of matching the raw string as one glob. Also
// authorize the verbatim command: decomposition drops cd/set-location segments
// and strips chaining metacharacters, so a payload like `cd $HOME; cat secret`
// would otherwise slip past the metachar deny rules (`*;*`, `*|*`, `*\n*`) and
// hide the escape. Keeping the raw string as a pattern makes those rules fire.
// Decompose each command into sub-command patterns + out-of-project dirs via the shared
// bash scan, so deny/plan-mode rules and external_directory checks apply per sub-command.
// Also authorize the verbatim string: decomposition drops cd/chaining metacharacters, so
// `cd $HOME; cat secret` would otherwise dodge the `*;*`/`*|*`/`*\n*` deny rules.
const patterns = new Set<string>()
const dirs = new Set<string>()
for (const command of commands) {
@@ -93,19 +66,13 @@ export namespace SkillInject {
for (const dir of scan.dirs) dirs.add(dir)
}
// Fail closed: an empty pattern set would make the bash ask below auto-approve
// (Permission.ask iterates patterns, so forceAsk/veto never run for an empty
// list). Each command contributes its verbatim string above, so this is
// unreachable — but abort rather than risk a silent, unprompted execution.
// Fail closed: an empty pattern set would auto-approve the ask below. Unreachable since
// each command adds its own string above, but abort rather than risk silent execution.
if (patterns.size === 0) return yield* Effect.die(new Error("skill shell produced no authorizable commands"))
// Up-front approval before any command runs: a bash ask naming every command, preceded
// by a separate external_directory ask when a sub-command touches a directory outside the
// project (below). `patterns` are the decomposed sub-commands used for rule matching;
// `metadata.commands` is the verbatim per-placeholder list the prompt displays, so what is
// shown is exactly what runs (decomposition drops cd/set-location segments and splits
// pipelines, which must not hide from the user). `skillShell` forces both prompts over
// allow/YOLO rules; a deny/veto on any sub-command propagates as a defect and aborts.
// One up-front bash ask naming every command (metadata.commands is the verbatim list
// shown, since decomposition can drop/split segments); external_directory asks first if
// any command leaves the project. `skillShell` forces both asks past allow/YOLO rules.
const metadata = { skillShell: true, skill: opts.skill, commands }
if (dirs.size > 0) {
yield* opts.ctx.ask({
@@ -122,9 +89,7 @@ export namespace SkillInject {
metadata,
})
// Run each command in the instance directory, bounded per-command by ctx.abort (ESC)
// and a timeout, and across the batch by an aggregate wall-clock budget, with output
// truncated so it can't blow up or poison the prompt.
// Run each command, bounded per-command by ctx.abort/timeout and by an aggregate budget.
const outputs = new Map<string, string>()
const deadline = Date.now() + BUDGET_MS
for (const command of commands) {
@@ -135,27 +100,23 @@ export namespace SkillInject {
outputs.set(command, yield* run(command, shell, opts.cwd, opts.ctx.abort))
}
// A placeholder that was capped out of `commands` isn't in `outputs`; mark it rather
// than silently inlining an empty string.
// Mark commands capped out of `commands` rather than silently inlining "".
return replace((command) => outputs.get(command) ?? LIMIT_NOTE)
})
const run = Effect.fn("SkillInject.run")(function* (command: string, shell: string, cwd: string, abort: AbortSignal) {
const timeout = new AbortController()
// A cleared timer bounds the run without leaking a pending 2-minute timeout per command;
// ESC (ctx.abort) still kills the child via the same combined signal.
const signal = AbortSignal.any([abort, timeout.signal])
const timer = setTimeout(() => timeout.abort(), TIMEOUT_MS)
const result = yield* Effect.promise(() =>
Process.text([command], { shell, cwd, abort: signal, nothrow: true }).catch(() => undefined),
).pipe(Effect.ensuring(Effect.sync(() => clearTimeout(timer))))
// With nothrow the promise resolves even when the child was killed, inlining partial
// stdout; detect the kill via the signals so an aborted/timed-out command is marked.
// nothrow resolves even on kill, inlining partial stdout; check the signals to mark it.
if (abort.aborted) return "[skill shell command aborted]"
if (timeout.signal.aborted) return "[skill shell command timed out]"
if (!result) return "[skill shell command failed]"
// A failing command with empty stdout would inline ""; surface a marker with any stderr.
// Empty stdout on failure would inline ""; surface a marker with any stderr instead.
if (result.code !== 0 && result.text.length === 0) {
const err = result.stderr.toString().trim()
return err ? "[skill shell command failed]\n" + truncate(err) : "[skill shell command failed]"
@@ -170,32 +131,106 @@ export namespace SkillInject {
return buf.toString("utf8", 0, MAX_OUTPUT_BYTES) + "\n[skill shell output truncated]"
}
// Rewrite only live (unfenced) placeholders in the ORIGINAL content, substituting once and
// never re-scanning the result, so inlined output containing `!`cmd`` stays inert and a
// fenced documentation example is left as literal text.
function rewrite(content: string, fenced: (index: number) => boolean, value: (command: string) => string) {
// Rewrites only live placeholders, once, in the original content — inlined output
// containing `!`cmd`` stays inert, and documentation examples stay literal text.
function rewrite(content: string, inert: (index: number) => boolean, value: (command: string) => string) {
return content.replace(ConfigMarkdown.SHELL_REGEX, (match, command: string, index: number) =>
fenced(index) ? match : value(command),
inert(index) ? match : value(command),
)
}
// Return a predicate that reports whether a character offset falls inside a fenced code
// block (``` or ~~~), so placeholders in documentation examples are treated as inert.
function fences(content: string): (index: number) => boolean {
const ranges: Array<[number, number]> = []
// Fenced code block spans (``` or ~~~), sorted and non-overlapping by construction.
function fenceSpans(content: string): Array<[number, number]> {
const spans: Array<[number, number]> = []
const fence = /^[ \t]*(`{3,}|~{3,})[^\n]*$/gm
let open: { start: number; marker: string } | undefined
for (const m of content.matchAll(fence)) {
const marker = m[1]
// CommonMark: a closing fence uses the same char and is at least as long as the opener,
// so an inner shorter/different fence stays content. Keep the real opener length.
// A closing fence uses the same char and is at least as long as the opener (CommonMark).
if (!open) open = { start: m.index, marker }
else if (marker[0] === open.marker[0] && marker.length >= open.marker.length) {
ranges.push([open.start, m.index + m[0].length])
spans.push([open.start, m.index + m[0].length])
open = undefined
}
}
if (open) ranges.push([open.start, content.length]) // unterminated fence runs to EOF
return (index: number) => ranges.some(([s, e]) => index >= s && index < e)
if (open) spans.push([open.start, content.length]) // unterminated fence runs to EOF
return spans
}
// Also treats inline code spans of 2+ backticks as inert: a single-backtick span can't
// contain a backtick, so a single-backtick pair nested in a longer run (e.g. `` !`cmd` ``)
// is always documentation, never a real placeholder.
function ranges(content: string): (index: number) => boolean {
const fences = fenceSpans(content)
const fenced = within(fences)
const spans: Array<[number, number]> = []
// Neither a fence nor a blank line can be crossed by an inline span, so pair backtick
// runs one chunk at a time, split on both.
for (const chunk of chunks(content, fences)) spans.push(...pairs(chunk))
return (index: number) => fenced(index) || within(spans)(index)
}
// Splits content into the parts outside `cuts` (fenced spans), then further splits each
// part on blank lines, keeping each chunk's absolute start offset.
function chunks(content: string, cuts: Array<[number, number]>): Array<{ start: number; text: string }> {
const out: Array<{ start: number; text: string }> = []
const push = (from: number, to: number) => {
const slice = content.slice(from, to)
const blank = /\n[ \t]*\n/g
let start = 0
for (const m of slice.matchAll(blank)) {
out.push({ start: from + start, text: slice.slice(start, m.index) })
start = m.index + m[0].length
}
out.push({ start: from + start, text: slice.slice(start) })
}
let pos = 0
for (const [s, e] of cuts) {
if (s > pos) push(pos, s)
pos = e
}
if (pos < content.length) push(pos, content.length)
return out
}
// Pairs backtick runs within one chunk: an opener closes at the *next* run of equal length
// (CommonMark), and everything between is then consumed rather than rescanned, so spans
// never nest or overlap and come out in increasing order. An opener with no closer is left
// as literal text and doesn't affect later pairing.
function pairs(chunk: { start: number; text: string }): Array<[number, number]> {
const runs = Array.from(chunk.text.matchAll(/`+/g)).map((m) => ({ start: chunk.start + m.index, len: m[0].length }))
const next: number[] = new Array(runs.length).fill(-1)
const last = new Map<number, number>()
for (let i = runs.length - 1; i >= 0; i--) {
next[i] = last.get(runs[i].len) ?? -1
last.set(runs[i].len, i)
}
const out: Array<[number, number]> = []
for (let i = 0; i < runs.length; ) {
if (runs[i].len < 2 || next[i] < 0) {
i++
continue
}
const j = next[i]
out.push([runs[i].start, runs[j].start + runs[j].len])
i = j + 1
}
return out
}
// Binary search over a sorted, non-overlapping [start, end) range list.
function within(spans: Array<[number, number]>): (index: number) => boolean {
return (index: number) => {
let lo = 0
let hi = spans.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
const [s, e] = spans[mid]
if (index < s) hi = mid - 1
else if (index >= e) lo = mid + 1
else return true
}
return false
}
}
}
@@ -13,6 +13,10 @@ import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "kilocode.system-prompt" })
export namespace KilocodeSystemPrompt {
export function shouldIncludePersona(agent: string) {
return agent !== "title" && agent !== "branch-name"
}
export function environment(input: { ctx: InstanceContext; model: Provider.Model; editor?: EditorContext }) {
return [
[
@@ -9,6 +9,7 @@ import { NotebookEditTool, NotebookExecuteTool, NotebookReadTool } from "./noteb
import { MemoryRecallTool } from "./memory-recall"
import { MemorySaveTool } from "./memory-save"
import { NotifyUserTool } from "./notify-user"
import { SendFileTool } from "./send-file"
import * as Tool from "../../tool/tool"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Effect } from "effect"
@@ -80,14 +81,15 @@ export namespace KiloToolRegistry {
// context here and injects it into the tool's init Effect.
const sessions = yield* KiloSessions.Service
const notify = yield* NotifyUserTool.pipe(Effect.provideService(KiloSessions.Service, sessions))
const send = yield* SendFileTool
if (!notebook)
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify }
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send }
const tools = yield* Effect.all({
notebookRead: NotebookReadTool,
notebookEdit: NotebookEditTool,
notebookExecute: NotebookExecuteTool,
}).pipe(Effect.provideService(Notebook.Service, notebook))
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, ...tools }
return { codebase, recall, managerModels, memory, save, manager, process, image, terminal, notify, send, ...tools }
})
}
@@ -105,6 +107,7 @@ export namespace KiloToolRegistry {
image: Tool.Info
terminal?: Tool.Info
notify: Tool.Info
send: Tool.Info
notebookRead?: Tool.Info
notebookEdit?: Tool.Info
notebookExecute?: Tool.Info
@@ -123,6 +126,7 @@ export namespace KiloToolRegistry {
process: Tool.init(tools.process),
image: Tool.init(tools.image),
notify: Tool.init(tools.notify),
send: Tool.init(tools.send),
})
const terminal = tools.terminal ? yield* Tool.init(tools.terminal) : undefined
const notebooks =
@@ -134,7 +138,7 @@ export namespace KiloToolRegistry {
})
: {}
const semantic = yield* semanticTool(deps, loaders)
return { ...base, terminal, ...notebooks, semantic, notify: base.notify }
return { ...base, terminal, ...notebooks, semantic, notify: base.notify, send: base.send }
})
}
@@ -178,6 +182,7 @@ export namespace KiloToolRegistry {
/** Hide human-driven tools from agents that cannot interact with the user directly. */
export function available(tool: Tool.Def, agent: Agent.Info) {
if (tool.id === "notify_user") return KiloSessions.remoteStatus().enabled
if (tool.id === "send_file") return KiloSessions.remoteStatus().connected
if (tool.id !== "interactive_terminal") return true
return agent.mode === "primary"
}
@@ -196,6 +201,7 @@ export namespace KiloToolRegistry {
image: Tool.Def
terminal?: Tool.Def
notify: Tool.Def
send: Tool.Def
notebookRead?: Tool.Def
notebookEdit?: Tool.Def
notebookExecute?: Tool.Def
@@ -221,6 +227,7 @@ export namespace KiloToolRegistry {
? [tools.notebookRead, tools.notebookEdit, tools.notebookExecute]
: []),
tools.notify,
tools.send,
]
}
@@ -0,0 +1,167 @@
import { Tool } from "@/tool/tool"
import { Effect, Schema } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { assertExternalDirectoryEffect } from "@/tool/external-directory"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import { KiloReadObject } from "@/kilocode/tool/read-object"
import { sniffAttachmentMime } from "@/util/media"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { KiloReference } from "@/kilocode/reference/contains"
import DESCRIPTION from "./send-file.txt"
import path from "node:path"
/**
* Remote-CLI-only live-connection delivery cap. The send_file path rides the
* existing tool-attachment transport (remote-sender → UserConnectionDO → mobile
* SDK), which has no in-repo frame cap. Cloud-agent ingest trims tool-attachment
* URLs at 1 MiB (`MAX_INGEST_EVENT_BYTES`), so delivery through the cloud-agent
* path is impossible by design — the tool is gated on `KiloSessions.remoteStatus()
* .connected`, which is only true for the remote-CLI relay. History/cold-open
* re-hydration rides the existing R2 spill (>~1.94 MiB) and 8 MiB page budget;
* near the cap a cold-open "unavailable" is accepted page-pressure behavior.
*/
export const SEND_FILE_MAX_BYTES = 4 * 1024 * 1024
const SAMPLE_BYTES = 4096
const Params = Schema.Struct({
path: Schema.String.annotate({ description: "Absolute or relative path to the file to send to the mobile app." }),
})
function fail(msg: string) {
return { title: "Send file failed", output: msg, metadata: {} }
}
export const SendFileTool = Tool.define<typeof Params, {}, FSUtil.Service, "send_file">(
"send_file",
Effect.gen(function* () {
const fs = yield* FSUtil.Service
return {
description: DESCRIPTION,
parameters: Params,
execute: (params, ctx) =>
Effect.gen(function* () {
if (!KiloSessions.remoteStatus().connected) {
return fail(
"Cannot send files: this session is not connected to Kilo cloud. Delivery needs an active link.",
)
}
const inst = yield* InstanceState.context
const requested = path.resolve(inst.directory, params.path)
const basename = path.basename(requested)
// kilocode_change start — authorize missing and directory paths with the same
// security sequence as read.ts before any file inspection via KiloReadObject.
// Route absent targets through a read-style authorized failure, and produce a
// structured fail() for directories. This prevents access-pattern leakage where
// missing vs directory vs external-directory errors differ before permission
// checks.
const info = yield* fs.stat(requested).pipe(
Effect.catchIf(
(err) => "reason" in err && err.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
)
if (!info) {
const dir = path.dirname(requested)
const parent = yield* fs.realPath(dir).pipe(Effect.option)
if (parent._tag === "None") return fail(`File not found: ${basename}`)
yield* assertExternalDirectoryEffect(ctx, parent.value, { bypass: false, kind: "directory" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, parent.value].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})
return fail(`File not found: ${basename}`)
}
if (info.type === "Directory") {
const resolved = yield* fs.realPath(requested)
const target = process.platform === "win32" ? FSUtil.normalizePath(resolved) : resolved
const explicit =
typeof ctx.extra?.["referenceRoot"] === "string"
? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], target).pipe(
Effect.option,
Effect.map((result) => result._tag === "Some" && result.value),
)
: false
yield* assertExternalDirectoryEffect(ctx, target, { bypass: explicit, kind: "directory" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, target].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})
return fail(`Cannot send: ${basename} is a directory.`)
}
// kilocode_change end
// 1. Resolve via KiloReadObject.file (same authorization sequence as read.ts)
const file = yield* KiloReadObject.file(requested)
// 2. Authorization — same pattern as read.ts
const explicit =
typeof ctx.extra?.["referenceRoot"] === "string"
? yield* KiloReference.path(fs, ctx.extra["referenceRoot"], file.target).pipe(
Effect.option,
Effect.map((result) => result._tag === "Some" && result.value),
)
: false
yield* assertExternalDirectoryEffect(ctx, file.target, { bypass: explicit, kind: "file" })
yield* ctx.ask({
permission: "read",
patterns: [...new Set([requested, file.target].map((item) => path.relative(inst.worktree, item)))],
always: ["*"],
metadata: {},
})
// 3. Size check before reading content
if (Number(file.stat.size) > SEND_FILE_MAX_BYTES) {
return {
title: "Send file too large",
output: `Cannot send: ${basename} is ${file.stat.size} bytes, which exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`,
metadata: {},
}
}
// 4. Open and read with TOCTOU safety (same pattern as read.ts)
return yield* KiloReadObject.use(file, (bound) =>
Effect.gen(function* () {
const sample = yield* Effect.tryPromise({
try: (signal) => bound.sample(SAMPLE_BYTES, AbortSignal.any([ctx.abort, signal])),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
})
const mime = sniffAttachmentMime(sample, FSUtil.mimeType(requested))
const bytes = yield* Effect.tryPromise({
try: (signal) => bound.read(SEND_FILE_MAX_BYTES + 1, AbortSignal.any([ctx.abort, signal])),
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
})
if (bytes.byteLength > SEND_FILE_MAX_BYTES) {
return {
title: "Send file too large",
output: `Cannot send: ${basename} exceeds the ${SEND_FILE_MAX_BYTES / (1024 * 1024)} MiB limit. For larger files, give the user the workspace path instead.`,
metadata: {},
}
}
return {
title: `Sent file: ${basename}`,
output: `File ${basename} (${bytes.byteLength} bytes, ${mime}) delivered to the user's Kilo app. Older app builds ignore non-image file attachments — make sure the user has an up-to-date app to see the delivery.`,
metadata: {},
attachments: [
{
type: "file" as const,
mime,
filename: basename,
url: `data:${mime};base64,${bytes.toString("base64")}`,
},
],
}
}),
)
}).pipe(Effect.orDie),
}
}),
)
@@ -0,0 +1,14 @@
Send a file from the local machine to the user's Kilo app. This tool works only when this session is connected to Kilo cloud (remote CLI relay). It sends exactly one file per call, up to 4 MiB.
Use this tool ONLY for:
- Files the user explicitly asked to see on mobile ("show me the log file on my phone")
- Sharing a generated file (a report, chart, or artifact) so the user can view it in the app
- Sending a screenshot or image the user requested
Do NOT use this tool:
- For files the user did not ask to see — the user pulls files on demand
- For every file you generate or read — only when the user asks for it on mobile
- For files larger than 4 MiB — the tool rejects them; give the user the workspace path instead
- In a cloud-agent session — delivery is remote-CLI only
The filename visible on mobile is always the basename, never a full path. Older Kilo app builds ignore non-image file deliveries.
+2 -3
View File
@@ -232,9 +232,8 @@ export const layer = Layer.effect(
}
// kilocode_change end
if (rule.action === "deny") {
return yield* new DeniedError({
ruleset: subset(request.permission, ruleset), // kilocode_change
})
// kilocode_change - carry the deciding rule (not just the permission subset) for provenance
return yield* new DeniedError({ ruleset: rule })
}
// kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule
if (forceAsk) {
+2 -1
View File
@@ -1258,10 +1258,11 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model
variants: {},
}
Object.assign(base, patchKiloModel(provider.id, model)) // kilocode_change
const variants = ProviderTransform.reasoningVariants(model, base) ?? ProviderTransform.variants(base) // kilocode_change
return {
...base,
variants: mapValues(ProviderTransform.variants(base), (v) => v),
variants: mapValues(variants, (v) => v), // kilocode_change
}
}
+222
View File
@@ -1154,6 +1154,228 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
return {}
}
// kilocode_change start - derive variants from models.dev reasoning_options
// (snatched from upstream v1.18.11, #36624 + follow-up fixes). Takes precedence
// over the heuristic variants() when the model publishes reasoning_options.
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
const options = model.reasoning_options
if (options === undefined) return
if (options.length === 0) return {}
const effort = options.find((option) => option.type === "effort")
if (effort) return effortVariants(target, effort.values)
const toggle = options.some((option) => option.type === "toggle")
const budget = options.find((option) => option.type === "budget_tokens")
if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
return nonEmptyVariants({
...(toggle ? reasoningToggle(target) : {}),
...budgetVariants(target, budget.min, budget.max),
})
}
function effortVariants(model: Provider.Model, values: readonly unknown[]) {
return Object.fromEntries(
values.flatMap((value) => {
const id = (() => {
if (value === null) return "none"
if (typeof value === "string") return value
})()
if (id === undefined) return []
const settings = reasoningEffort(model, id)
return settings ? [[id, settings]] : []
}),
)
}
function budgetVariants(model: Provider.Model, min?: number, max?: number) {
const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1)
if (maximum <= 0) return {}
const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return Object.fromEntries(
[
{ id: "high", budget: high },
{ id: "max", budget: maximum },
].flatMap((item) => {
const settings = reasoningBudget(model, item.budget)
return settings ? [[item.id, settings]] : []
}),
)
}
function nonEmptyVariants(variants: NonNullable<Provider.Model["variants"]>): Provider.Model["variants"] {
return Object.keys(variants).length > 0 ? variants : undefined
}
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
if (model.api.npm === "@ai-sdk/alibaba")
return {
none: { enableThinking: false },
high: { enableThinking: true },
}
if (model.api.npm === "@ai-sdk/cohere")
return {
none: { thinking: { type: "disabled" } },
high: { thinking: { type: "enabled" } },
}
return {}
}
function reasoningEffort(model: Provider.Model, effort: string) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { effort } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return anthropicEffort(model, effort) ?? { effort }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
case "@ai-sdk/amazon-bedrock":
if (anthropicAdaptiveEfforts(model.api.id))
return {
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
}
if (anthropicOpus45(model.api.id))
return {
reasoningConfig: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
maxReasoningEffort: effort,
},
}
if (model.api.id.includes("anthropic")) return
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
return { reasoningEffort: effort }
case "@ai-sdk/github-copilot":
// OAuth discovery replaces these with variants from Copilot's /models capabilities.
if (model.id.includes("gemini")) return
if (model.id.includes("claude")) return { reasoningEffort: effort }
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@ai-sdk/openai":
case "@ai-sdk/amazon-bedrock/mantle":
return { reasoningEffort: effort, reasoningSummary: reasoningSummary(model), include: INCLUDE_ENCRYPTED_REASONING } // kilocode_change - keep gpt-5.6 detailed summaries
case "@ai-sdk/azure":
return { reasoningEffort: effort, reasoningSummary: reasoningSummary(model), include: INCLUDE_ENCRYPTED_REASONING } // kilocode_change
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic"))
return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } }
return { modelParams: { reasoning_effort: effort } }
case "@ai-sdk/openai-compatible":
case "@ai-sdk/xai":
case "@ai-sdk/mistral":
case "@ai-sdk/groq":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/togetherai":
case "venice-ai-sdk-provider":
case "ai-gateway-provider":
return { reasoningEffort: effort }
case "@kilocode/kilo-gateway": // kilocode_change - OpenRouter-shaped reasoning effort
return { reasoning: { effort } } // kilocode_change
case "@ai-sdk/cohere":
case "@ai-sdk/perplexity":
case "@ai-sdk/vercel":
case "@ai-sdk/alibaba":
case "gitlab-ai-provider":
return
}
}
function anthropicEffort(model: Provider.Model, effort: string) {
if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort)
// Kimi defaults to omitting adaptive thinking text unless summarized display is requested.
if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (!anthropicAdaptiveEfforts(model.api.id)) return
return {
thinking: {
type: "adaptive",
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
effort,
}
}
function anthropicOpus45Effort(model: Provider.Model, effort: string) {
return {
thinking: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
},
effort,
}
}
function reasoningBudget(model: Provider.Model, budget: number) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { max_tokens: budget } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return { thinking: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
case "@ai-sdk/amazon-bedrock":
return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
return
case "@ai-sdk/cohere":
return { thinking: { type: "enabled", tokenBudget: budget } }
case "@ai-sdk/alibaba":
return { enableThinking: true, thinkingBudget: budget }
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic")) return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } }
if (model.id.includes("gemini"))
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
return
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/azure":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/github-copilot":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
case "@ai-sdk/openai":
case "@ai-sdk/openai-compatible":
case "@ai-sdk/perplexity":
case "@ai-sdk/togetherai":
case "@ai-sdk/vercel":
case "@ai-sdk/xai":
case "ai-gateway-provider":
case "gitlab-ai-provider":
case "venice-ai-sdk-provider":
return
}
}
function isKimiFamily(model: Provider.Model) {
if (
[model.providerID, model.api.id].some((id) => {
const value = id.toLowerCase()
return value.includes("kimi") || value.includes("moonshot")
})
)
return true
const url = model.api.url.toLowerCase()
return ["api.kimi.com", "api.moonshot.ai", "api.moonshot.cn", "api.moonshotai.cn"].some((host) => url.includes(host))
}
function anthropicOpus45(apiId: string) {
return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value))
}
// kilocode_change end
export function options(input: {
model: Provider.Model
sessionID: string
+7 -5
View File
@@ -27,6 +27,7 @@ import {
import { Identity } from "@kilocode/kilo-telemetry"
import { KiloSession } from "@/kilocode/session"
import { stripInternalOptions } from "@/kilocode/agent/options"
import { KilocodeSystemPrompt } from "@/kilocode/system-prompt"
// kilocode_change end
type PrepareInput = {
@@ -67,10 +68,11 @@ const mergeOptions = (target: Record<string, any>, source: Record<string, any> |
export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
const includePersona = KilocodeSystemPrompt.shouldIncludePersona(input.agent.name) // kilocode_change
const system = [
[
// kilocode_change start - soul defines core identity and personality
...(isOpenaiOauth ? [] : [SystemPrompt.soul()]),
...(isOpenaiOauth || !includePersona ? [] : [SystemPrompt.soul()]),
// kilocode_change end
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system,
@@ -116,10 +118,10 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre
delete options.include
}
if (isOpenaiOauth) {
// kilocode_change start - prepend soul to instructions
options.instructions = SystemPrompt.soul() + "\n" + system.join("\n")
// kilocode_change end
}
// kilocode_change start - prepend soul to instructions
options.instructions = [...(includePersona ? [SystemPrompt.soul()] : []), ...system].join("\n")
// kilocode_change end
}
const messages =
isOpenaiOauth || input.isWorkflow
+7 -1
View File
@@ -428,7 +428,13 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
const outputText = part.state.time.compacted
? "[Old tool result content cleared]"
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// kilocode_change start — do not replay send_file delivery attachments to the model;
// they are mobile delivery artifacts (up to 4 MiB base64), not model context.
const attachments =
part.state.time.compacted || options?.stripMedia || part.tool === "send_file"
? []
: (part.state.attachments ?? [])
// kilocode_change end
// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
+2 -3
View File
@@ -30,8 +30,7 @@ export function isOverflow(input: {
if (input.model.limit.context === 0) return false
const count = KiloSessionOverflow.count(input.tokens) // kilocode_change
// kilocode_change start
const cap = KiloSessionOverflow.limit({ cfg: input.cfg, model: input.model, usable: usable(input) })
return count >= cap
// kilocode_change start - post-step checks are safety-only; economic thresholds run in preflight
return count >= usable(input)
// kilocode_change end
}
+8 -1
View File
@@ -706,8 +706,14 @@ export const layer = Layer.effect(
return
}
const rawOutput = toolResultOutput(value)
// kilocode_change start — send_file delivery attachments (up to 4 MiB raw)
// must reach mobile byte-for-byte. Base64-encoded images near the cap can
// exceed the generic 5 MiB normalization limit, causing rewrites or omission
// after the tool reports success. These attachments are delivery-only; the
// existing message-v2 filter already strips them from model context.
const skipNormalization = value.name === "send_file"
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
attachment.mime.startsWith("image/")
attachment.mime.startsWith("image/") && !skipNormalization
? image.normalize(attachment).pipe(
Effect.catchIf(
(error) => error instanceof Image.ResizerUnavailableError,
@@ -717,6 +723,7 @@ export const layer = Layer.effect(
)
: Effect.succeed(Exit.succeed<SessionV1.FilePart>(attachment)),
)
// kilocode_change end
const omitted = normalized.filter(Exit.isFailure).length
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
const output = {
+15
View File
@@ -26,6 +26,7 @@ import { ModelV2 } from "@opencode-ai/core/model"
// kilocode_change start
import { SwePruner } from "@/kilocode/swe-pruner"
import { Config } from "@/config/config"
import { PermissionProvenance } from "@/kilocode/permission/provenance"
// kilocode_change end
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
@@ -82,6 +83,20 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
}).pipe(
// record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask
Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })),
// record why the call was denied too, so JSON exports and clients can explain the denial
Effect.tapErrorTag("PermissionDeniedError", (err) =>
input.processor.metadata(options.toolCallId, {
metadata: {
approval: PermissionProvenance.classifyDenial({
ruleset: err.ruleset,
permission: req.permission,
patterns: req.patterns,
agent: input.agent.name,
origins: permissionOrigins,
}),
},
}),
),
Effect.asVoid,
Effect.orDie,
),
+3 -3
View File
@@ -83,13 +83,13 @@ async function search(
}),
) // kilocode_change
const coverage = `Searched ${found.sessions} sessions and ${found.parts} transcript parts.`
const coverage = `Searched ${found.sessions} sessions and evaluated ${found.candidates} transcript candidates.`
const query = RecallSearch.inert(params.query)
if (found.results.length === 0) {
return {
title: `Search: "${query}" (no results)`,
output: RecallSearch.inert(`No sessions found matching "${params.query}". ${coverage}`),
metadata: { searchedSessions: found.sessions, searchedParts: found.parts },
metadata: { searchedSessions: found.sessions, candidateParts: found.candidates },
}
}
@@ -107,7 +107,7 @@ async function search(
return {
title: `Search: "${query}" (${found.results.length} results)`,
output: RecallSearch.inert(lines.join("\n")),
metadata: { searchedSessions: found.sessions, searchedParts: found.parts },
metadata: { searchedSessions: found.sessions, candidateParts: found.candidates },
}
}
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Layer } from "effect"
import { Bus } from "../../../src/bus"
import { Permission } from "../../../src/permission"
import { PermissionProvenance } from "../../../src/kilocode/permission/provenance"
import { EventV2Bridge } from "../../../src/event-v2-bridge"
import { Database } from "@opencode-ai/core/database/database"
import { SessionID } from "../../../src/session/schema"
import * as Config from "../../../src/config/config"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { provideTmpdirInstance } from "../../fixture/fixture"
import { testEffect } from "../../lib/effect"
const env = Layer.mergeAll(
Permission.layer.pipe(
Layer.provide(EventV2Bridge.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Database.defaultLayer),
),
Config.defaultLayer,
Bus.layer,
CrossSpawnSpawner.defaultLayer,
)
const it = testEffect(env)
const ask = (input: Parameters<Permission.Interface["ask"]>[0]) =>
Effect.gen(function* () {
const permission = yield* Permission.Service
return yield* permission.ask(input)
})
function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect<any, any, any>) {
return provideTmpdirInstance(self, options)
}
describe("Permission.ask denial provenance", () => {
it.live(
"attributes a denial to the rule that matched the request's pattern, not just the textually-last deny rule for the permission",
() =>
withDir({ git: true }, () =>
Effect.gen(function* () {
// Two deny rules under the same permission for different patterns. Matching by
// permission alone (e.g. findLast over rules with action "deny") would pick
// "rm -rf *" here since it sorts last, even though "git push *" is the one that
// actually matched the request.
const ruleset = [
{ permission: "bash", pattern: "git push *", action: "deny" as const },
{ permission: "bash", pattern: "rm -rf *", action: "deny" as const },
]
const exit = yield* ask({
sessionID: SessionID.make("session_test"),
permission: "bash",
patterns: ["git push origin main"],
metadata: {},
always: [],
ruleset,
}).pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
const err = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
expect(err).toBeInstanceOf(Permission.DeniedError)
const approval = PermissionProvenance.classifyDenial({
ruleset: (err as Permission.DeniedError).ruleset,
permission: "bash",
patterns: ["git push origin main"],
agent: "build",
origins: undefined,
})
expect(approval.rule).toEqual({ permission: "bash", pattern: "git push *", action: "deny" })
}),
),
)
test("a denial with no specific rule (e.g. a headless-subagent policy denial) is still reported as denied, not as an ambiguous default approval", () => {
// Some denial paths don't carry a specific rule -- Permission.ask's headless-subagent policy
// denial, for instance, still sets `ruleset` to the plain deny-permission subset (an array,
// with no `.action`/`.pattern` of its own). classify({ rule: undefined }) reports
// { source: "default" } -- the same shape the *approval* fallback produces for "no rule
// matched" -- so without a synthesized deny rule, a refusal would render (and export) as an
// auto-approval.
const approval = PermissionProvenance.classifyDenial({
ruleset: [{ permission: "bash", pattern: "*", action: "ask" as const }],
permission: "bash",
patterns: ["rm -rf /"],
agent: "build",
origins: undefined,
})
expect(approval.rule?.action).toBe("deny")
expect(approval.rule).toEqual({ permission: "bash", pattern: "rm -rf /", action: "deny" })
})
})
@@ -0,0 +1,140 @@
import { describe, expect, test } from "bun:test"
import { ProviderTransform } from "../../src/provider/transform"
import { Provider } from "../../src/provider/provider"
import type * as ModelsDev from "@opencode-ai/core/models-dev"
function mockModel(overrides: Partial<any> = {}): any {
return {
id: "test/test-model",
providerID: "test",
api: {
id: "test-model",
url: "https://api.test.com",
npm: "@ai-sdk/openai",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
limit: { context: 200_000, output: 64_000 },
status: "active",
options: {},
headers: {},
release_date: "2024-01-01",
...overrides,
}
}
function raw(options: ModelsDev.Model["reasoning_options"]): ModelsDev.Model {
return { reasoning_options: options } as ModelsDev.Model
}
describe("ProviderTransform.reasoningVariants - models.dev reasoning_options", () => {
test("effort tiers including 'max' and null 'none' on @ai-sdk/openai", () => {
const target = mockModel({
api: { id: "gpt-5.6", url: "https://api.openai.com", npm: "@ai-sdk/openai" },
})
const result = ProviderTransform.reasoningVariants(
raw([{ type: "effort", values: ["none", null, "low", "medium", "high", "xhigh", "max"] }]),
target,
)
expect(Object.keys(result ?? {})).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(result?.none).toEqual({
reasoningEffort: "none",
reasoningSummary: "detailed",
include: ["reasoning.encrypted_content"],
})
expect(result?.max).toEqual({
reasoningEffort: "max",
reasoningSummary: "detailed",
include: ["reasoning.encrypted_content"],
})
})
test("effort tiers on @openrouter/ai-sdk-provider use reasoning object shape", () => {
const target = mockModel({
providerID: "openrouter",
api: { id: "openai/gpt-5.6", url: "https://openrouter.ai", npm: "@openrouter/ai-sdk-provider" },
})
const result = ProviderTransform.reasoningVariants(
raw([{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }]),
target,
)
expect(Object.keys(result ?? {})).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(result?.max).toEqual({ reasoning: { effort: "max" } })
})
test("budget_tokens produces high/max budget variants on bedrock", () => {
const target = mockModel({
api: { id: "anthropic.claude-sonnet-4-5", url: "https://bedrock.amazonaws.com", npm: "@ai-sdk/amazon-bedrock" },
})
const result = ProviderTransform.reasoningVariants(raw([{ type: "budget_tokens", min: 1024 }]), target)
expect(Object.keys(result ?? {})).toEqual(["high", "max"])
expect(result?.max).toEqual({ reasoningConfig: { type: "enabled", budgetTokens: 31_999 } })
})
test("explicitly empty reasoning_options means no variants", () => {
const target = mockModel()
expect(ProviderTransform.reasoningVariants(raw([]), target)).toEqual({})
})
test("missing reasoning_options falls back to heuristics (undefined)", () => {
const target = mockModel()
expect(ProviderTransform.reasoningVariants(raw(undefined), target)).toBeUndefined()
})
test("models.dev reasoning_options take precedence over heuristic variants in the provider pipeline", () => {
const provider = {
id: "openai",
name: "OpenAI",
env: [],
npm: "@ai-sdk/openai",
models: {
"gpt-5.6": {
id: "gpt-5.6",
name: "GPT-5.6",
family: "gpt",
release_date: "2025-12-11",
attachment: true,
reasoning: true,
temperature: false,
tool_call: true,
cost: { input: 1, output: 4, cache_read: 0.5, cache_write: 0 },
limit: { context: 400_000, output: 128_000 },
reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }],
},
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
family: "gpt",
release_date: "2024-06-01",
attachment: true,
reasoning: true,
temperature: false,
tool_call: true,
cost: { input: 1, output: 4, cache_read: 0.5, cache_write: 0 },
limit: { context: 400_000, output: 128_000 },
},
},
} as unknown as ModelsDev.Provider
const info = Provider.fromModelsDevProvider(provider)
const gpt56 = info.models["gpt-5.6"]
expect(Object.keys(gpt56.variants ?? {})).toEqual(["none", "low", "medium", "high", "xhigh", "max"])
expect(gpt56.variants?.["max"]).toEqual({
reasoningEffort: "max",
reasoningSummary: "detailed",
include: ["reasoning.encrypted_content"],
})
const gpt5 = info.models["gpt-5"]
expect(Object.keys(gpt5.variants ?? {})).toEqual(["minimal", "low", "medium", "high"])
})
})
@@ -9,7 +9,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { eq, sql } from "drizzle-orm"
import { seedProject } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
@@ -68,6 +68,61 @@ function run(query: string, signal?: AbortSignal) {
signal,
})
}
it.instance(
"uses the recall covering index when available",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Planner" })
const { db } = yield* Database.Service
const plan = yield* db
.all<{
detail: string
}>(sql`EXPLAIN QUERY PLAN ${RecallSearch.query([session.id], ["needle"], { sessionID: "", partID: "" })}`)
.pipe(Effect.orDie)
expect(plan.some((row) => row.detail.includes("recall_part_search_idx"))).toBe(true)
}),
{ git: true },
)
it.instance(
"recreates the recall index lazily after it is missing",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Lazy index" })
yield* add(session.id, "user", { type: "text", text: "lazy index needle" })
const { db } = yield* Database.Service
yield* db.run(sql`DROP INDEX recall_part_search_idx`).pipe(Effect.orDie)
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'recall_part_search_idx'`),
).toBeUndefined()
expect((yield* run("lazy index needle")).results.map((item) => item.id)).toEqual([session.id])
expect(
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name = 'recall_part_search_idx'`),
).toEqual({ name: "recall_part_search_idx" })
}),
{ git: true },
)
it.instance(
"continues searching when lazy index creation fails",
() =>
Effect.gen(function* () {
yield* seedProject
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Unavailable index" })
yield* add(session.id, "user", { type: "text", text: "fallback needle" })
const { db } = yield* Database.Service
yield* db.run(sql`DROP INDEX recall_part_search_idx`).pipe(Effect.orDie)
yield* db.run(sql`PRAGMA query_only = ON`).pipe(Effect.orDie)
expect((yield* run("fallback needle")).results.map((item) => item.id)).toEqual([session.id])
}),
{ git: true },
)
it.instance(
"searches titles and terms distributed across transcript messages",
() =>
@@ -282,7 +337,7 @@ it.instance(
const result = yield* run("last-session-needle")
expect(result.results).toHaveLength(1)
expect(result.sessions).toBe(143)
expect(result.parts).toBe(1_102)
expect(result.candidates).toBe(1)
}),
{ git: true },
)
@@ -301,7 +356,7 @@ it.instance(
text: `terminal ${"x".repeat(20_000)} terminal needle ${"y".repeat(20_000)}`,
})
for (let index = 0; index < 1_100; index++) {
yield* add(session.id, "user", { type: "text", text: `noise ${index}` })
yield* add(session.id, "user", { type: "text", text: `paged noise ${index}` })
}
expect((yield* run("job_id 100%")).results.map((item) => item.id)).toEqual([session.id])
@@ -311,11 +366,12 @@ it.instance(
const snippet = (yield* run("terminal needle")).results[0]?.matches[0]?.text ?? ""
expect(snippet).toContain("terminal needle")
expect(snippet.length).toBeLessThan(370)
expect((yield* run("paged noise")).results.map((item) => item.id)).toEqual([session.id])
const database = yield* Database.Service
const controller = new AbortController()
const pending = Effect.runPromise(
run("absent-needle", controller.signal).pipe(Effect.provideService(Database.Service, database)),
run("paged noise", controller.signal).pipe(Effect.provideService(Database.Service, database)),
)
queueMicrotask(() => controller.abort(new Error("cancelled recall search")))
const error = yield* Effect.promise(() => pending.catch((value: unknown) => value))
@@ -388,6 +388,7 @@ describe("RemoteAttachments.create().materialize", () => {
expect(text.text).toContain("filename: blob.bin")
expect(text.text).toContain("mime: application/octet-stream")
expect(text.text).toContain(`size: ${bin.byteLength} bytes`)
expect(text.text).toContain("shell utilities")
const entries = await fs.readdir(dir)
expect(entries).toHaveLength(1)
@@ -157,14 +157,15 @@ const registry = Layer.effect(
const it = testEffect(registry)
const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? it.live : it.live.skip
function resolve(ctx: InstanceContext) {
function resolve(ctx: InstanceContext, metadataCalls: { toolCallID: string; value: Record<string, any> }[] = []) {
return SessionTools.resolve({
agent,
model,
session: session(ctx.directory),
processor: {
message: message(ctx),
metadata: () => Effect.void,
// capture metadata writes so tests can assert on recorded approval provenance
metadata: (toolCallID, value) => Effect.sync(() => void metadataCalls.push({ toolCallID, value })),
completeToolCall: () => Effect.void,
},
bypassAgentCheck: false,
@@ -355,3 +356,29 @@ mac("confines a model-originated sandboxed process to the active worktree", () =
expect(yield* exists(primary)).toBe(false)
}),
)
it.live("records why a denied tool call was refused on the tool part's metadata", () =>
Effect.gen(function* () {
const dirs = yield* fixture()
const metadataCalls: { toolCallID: string; value: Record<string, any> }[] = []
const deniedRule = { permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const }
const overrides = Layer.mergeAll(
TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }),
Layer.mock(Permission.Service)({
ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRule })),
}),
)
const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides))
const shell = tools.bash
if (!shell) yield* Effect.die(new Error("bash tool is missing"))
const result = yield* call(shell, { command: "echo hi", workdir: dirs.a }, "call-denied").pipe(Effect.exit)
expect(Exit.isFailure(result)).toBe(true)
const approval = metadataCalls.find((c) => c.toolCallID === "call-denied")?.value?.metadata?.approval
expect(approval).toEqual({
source: "project",
rule: { permission: "bash", pattern: "*", action: "deny" },
})
}),
)
@@ -0,0 +1,127 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import type { ModelMessage } from "ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { Agent } from "@/agent/agent"
import type { Auth } from "@/auth"
import { RuntimeFlags } from "@/effect/runtime-flags"
import type { Plugin } from "@/plugin"
import type { Provider } from "@/provider/provider"
import { LLMRequestPrep } from "@/session/llm/request"
import { MessageID, SessionID } from "@/session/schema"
import { SystemPrompt } from "@/session/system"
const model: Provider.Model = {
id: ModelV2.ID.make("test-model"),
providerID: ProviderV2.ID.make("test"),
api: {
id: "test-model",
url: "https://example.com/v1",
npm: "@ai-sdk/openai",
},
name: "Test model",
capabilities: {
temperature: true,
reasoning: false,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 128_000, output: 32_000 },
status: "active",
options: {},
headers: {},
release_date: "2026-01-01",
}
const plugin: Plugin.Interface = {
init: () => Effect.void,
trigger: (_name, _input, output) => Effect.succeed(output),
list: () => Effect.succeed([]),
}
function agent(name: string): Agent.Info {
return {
name,
mode: "primary",
options: {},
permission: [],
prompt: `${name} generation prompt`,
}
}
function user(name: string): SessionV1.User {
return {
id: MessageID.make("msg_test"),
sessionID: SessionID.make("ses_test"),
role: "user",
time: { created: Date.now() },
agent: name,
model: { providerID: model.providerID, modelID: model.id },
system: "request-specific system text",
}
}
async function prepare(name: string, oauth = false) {
const auth: Auth.Info | undefined = oauth
? { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }
: undefined
const provider: Provider.Info = {
id: ProviderV2.ID.make(oauth ? "openai" : "test"),
name: "Test provider",
source: "config",
env: [],
options: {},
models: {},
}
const flags = await Effect.runPromise(
RuntimeFlags.Service.pipe(Effect.provide(RuntimeFlags.layer({ client: "test" }))),
)
return Effect.runPromise(
LLMRequestPrep.prepare({
user: user(name),
sessionID: "ses_test",
model,
agent: agent(name),
system: [],
messages: [{ role: "user", content: "Generate a name" }] satisfies ModelMessage[],
tools: {},
provider,
auth,
plugin,
flags,
isWorkflow: false,
}),
)
}
describe("Kilo persona in generated metadata requests", () => {
test.each(["title", "branch-name"])("omits the persona for %s generation", async (name) => {
const result = await prepare(name)
expect(result.system[0]).toContain(`${name} generation prompt`)
expect(result.system[0]).toContain("request-specific system text")
expect(result.system[0]).not.toContain(SystemPrompt.soul())
})
test.each(["title", "branch-name"])("omits the persona from OpenAI OAuth %s generation", async (name) => {
const result = await prepare(name, true)
expect(result.params.options.instructions).toContain(`${name} generation prompt`)
expect(result.params.options.instructions).toContain("request-specific system text")
expect(result.params.options.instructions).not.toContain(SystemPrompt.soul())
})
test("keeps the persona for ordinary agent requests", async () => {
const result = await prepare("code")
const oauth = await prepare("code", true)
expect(result.system[0]).toContain(SystemPrompt.soul())
expect(oauth.params.options.instructions).toContain(SystemPrompt.soul())
})
})
@@ -47,16 +47,17 @@ function tokens(count: number): MessageV2.Assistant["tokens"] {
return { input: count, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
}
describe("Kilo auto-compaction threshold", () => {
test("triggers at the configured context percentage", () => {
describe("Kilo post-step compaction safety", () => {
test("ignores the configured threshold after a provider step", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(167_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(168_000) })).toBe(true)
})
test("keeps the reserved safety trigger when it is lower", () => {
test("uses the usable context limit when the threshold is high", () => {
const conf = cfg({ threshold_percent: 95 })
const mdl = model({ context: 200_000, output: 32_000 })
@@ -68,8 +69,8 @@ describe("Kilo auto-compaction threshold", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 400_000, input: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(149_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(150_000) })).toBe(true)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(179_999) })).toBe(false)
expect(isOverflow({ cfg: conf, model: mdl, tokens: tokens(180_000) })).toBe(true)
})
test("ignores a cleared threshold", () => {
@@ -114,14 +115,14 @@ describe("Kilo auto-compaction threshold", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(149_999), reasoning: 1 } })).toBe(true)
expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(167_999), reasoning: 1 } })).toBe(true)
})
test("falls back to provider total when normalized usage is unavailable", () => {
const conf = cfg({ threshold_percent: 75 })
const mdl = model({ context: 200_000, output: 32_000 })
expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(0), total: 150_000 } })).toBe(true)
expect(isOverflow({ cfg: conf, model: mdl, tokens: { ...tokens(0), total: 168_000 } })).toBe(true)
})
test("uses the output cap as the reserve for single-window gateway models", () => {
@@ -298,6 +298,127 @@ describe("skill shell injection", () => {
}),
)
unix("does not execute a placeholder shown as a double-backtick inline code example", () =>
Effect.gen(function* () {
// `` !`cmd` `` is the standard CommonMark way to display the literal `!`cmd`` syntax
// as documentation; only the live placeholder must run.
yield* writeGlobalSkill(
"inline-example-shell",
"Live: !`printf LIVE`\n\nSyntax: `` !`cmd` `` runs a command.",
)
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const result = yield* loadSkill("inline-example-shell", (req) =>
Effect.sync(() => {
requests.push(req)
}),
)
expect(result.output).toContain("Live: LIVE")
expect(result.output).toContain("Syntax: `` !`cmd` `` runs a command.")
const bash = requests.filter((r) => r.permission === "bash")
expect(bash[0]?.patterns).toEqual(["printf LIVE"])
}),
)
unix("does not ask or run anything for a skill with only an inline code example", () =>
Effect.gen(function* () {
// This is the real-world trigger: kilo-config.md documents the placeholder syntax
// with `` !`cmd` `` outside any fence, which must never request permission or run.
yield* writeGlobalSkill("doc-only-shell", "Template variables include `` !`cmd` `` (shell output).")
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const result = yield* loadSkill("doc-only-shell", (req) =>
Effect.sync(() => {
requests.push(req)
}),
)
expect(result.output).toContain("Template variables include `` !`cmd` `` (shell output).")
expect(requests.some((r) => r.permission === "bash")).toBe(false)
}),
)
unix("does not let distant unrelated inline code spans merge into one inert range", () =>
Effect.gen(function* () {
// Code spans cannot cross a blank line. Stray double-backticks in an earlier paragraph
// and a later, unrelated (unclosed) one must not pair across the live placeholder that
// sits between them and silently swallow it — that would skip both its execution and
// the marker that would otherwise flag a rejected/untrusted command.
const body = [
"Use the C++ operator `` and note the ``literal`` form.",
"",
"## Step 2",
"",
"!`printf LIVE`",
"",
"Done, see the output above.",
"",
"Trailing note about `` quoting.",
].join("\n")
yield* writeGlobalSkill("distant-spans-shell", body)
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const result = yield* loadSkill("distant-spans-shell", (req) =>
Effect.sync(() => {
requests.push(req)
}),
)
expect(result.output).toContain("LIVE")
const bash = requests.filter((r) => r.permission === "bash")
expect(bash[0]?.patterns).toEqual(["printf LIVE"])
}),
)
unix("does not treat a placeholder as live when it truly sits inside an outer code span", () =>
Effect.gen(function* () {
// A backtick pair nests inner backtick runs of other lengths as literal content, per
// CommonMark (the first equal-length run closes the span). The whole thing between the
// two `` here is one code span, so the placeholder-looking text inside it must stay
// inert — and, separately, the span-detection lookup must not treat it as live due to
// an internal nested/overlapping-range bug.
const body = "`` a ``` b ``` c ```` d ```` e !`printf SHOULDNOTRUN` f ``"
yield* writeGlobalSkill("nested-span-shell", body)
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const result = yield* loadSkill("nested-span-shell", (req) =>
Effect.sync(() => {
requests.push(req)
}),
)
// If it had run, the placeholder would be replaced by the command's stdout ("SHOULDNOTRUN"
// alone, without "printf" or the backticks); the literal placeholder text surviving intact
// proves it stayed inert.
expect(result.output).toContain("!`printf SHOULDNOTRUN`")
expect(requests.some((r) => r.permission === "bash")).toBe(false)
}),
)
unix("does not let backtick runs on either side of a fence pair across it", () =>
Effect.gen(function* () {
// A fenced block is a block-level boundary; an inline code span cannot cross it, so a
// stray double-backtick right before a fence (no blank line separating them) and another
// one right after must not pair up and swallow the live placeholder between them.
const body = ["Some `` text before.", "```", "fenced block", "```", "!`printf LIVE`", "More `` text after."].join(
"\n",
)
yield* writeGlobalSkill("fence-crossing-shell", body)
const requests: Array<Omit<PermissionV1.Request, "id" | "sessionID" | "tool">> = []
const result = yield* loadSkill("fence-crossing-shell", (req) =>
Effect.sync(() => {
requests.push(req)
}),
)
expect(result.output).toContain("LIVE")
const bash = requests.filter((r) => r.permission === "bash")
expect(bash[0]?.patterns).toEqual(["printf LIVE"])
}),
)
unix("does not re-execute shell placeholders emitted by command output", () =>
Effect.gen(function* () {
// The command emits a literal placeholder `!<backtick>echo pwned<backtick>`
@@ -372,6 +493,25 @@ describe("SkillInject.render gating", () => {
}),
)
it.effect(
"does not scale quadratically with fence and backtick-run count",
() =>
Effect.gen(function* () {
// A pathological SKILL.md with many fences plus many short backtick runs previously
// took ~20-30s (O(runs x fences) fence lookups, O(runs^2) pairing); the fixed version
// takes well under 100ms. The bound below is deliberately loose — this guards against
// a reintroduced quadratic path, not a latency SLA, so it must not flake on a loaded
// CI runner. This content runs before the trust check, so it must stay bounded even
// for an untrusted skill. The bun test timeout is raised to match (default 5s would
// otherwise abort the test well before the assertion's own bound is reached).
const content = "```\n```\n".repeat(40000) + "`x ".repeat(120000) + "!`printf ran`"
const started = Date.now()
yield* Effect.promise(() => run({ trusted: false, disabled: false, content }))
expect(Date.now() - started).toBeLessThan(20000)
}),
30000,
)
it.effect("content without placeholders is returned unchanged", () =>
Effect.gen(function* () {
const out = yield* Effect.promise(() => run({ trusted: true, disabled: false, content: "no commands here" }))
@@ -44,6 +44,7 @@ function infos() {
process: info("background_process"),
image: info("generate_image"),
notify: info("notify_user"),
send: info("send_file"),
notebookRead: info("notebook_read"),
notebookEdit: info("notebook_edit"),
notebookExecute: info("notebook_execute"),
@@ -342,6 +342,7 @@ describe("kilocode tool registry indexing", () => {
image: def("generate_image"),
terminal: def("interactive_terminal"),
notify: def("notify_user"),
send: def("send_file"),
notebookRead: def("notebook_read"),
notebookEdit: def("notebook_edit"),
notebookExecute: def("notebook_execute"),
@@ -357,6 +358,7 @@ describe("kilocode tool registry indexing", () => {
"background_process",
"interactive_terminal",
"notify_user",
"send_file",
])
expect(KiloToolRegistry.extra(tools, { experimental: { codebase_search: true } }).map((tool) => tool.id)).toEqual(
[
@@ -368,6 +370,7 @@ describe("kilocode tool registry indexing", () => {
"background_process",
"interactive_terminal",
"notify_user",
"send_file",
],
)
expect(
@@ -384,6 +387,7 @@ describe("kilocode tool registry indexing", () => {
"background_process",
"interactive_terminal",
"notify_user",
"send_file",
])
process.env["KILO_CLIENT"] = "vscode"
@@ -398,6 +402,7 @@ describe("kilocode tool registry indexing", () => {
"agent_manager_models",
"agent_manager",
"notify_user",
"send_file",
],
)
expect(
@@ -417,6 +422,7 @@ describe("kilocode tool registry indexing", () => {
"notebook_edit",
"notebook_execute",
"notify_user",
"send_file",
])
expect(KiloToolRegistry.extra({ ...tools, semantic: undefined }, {}).map((tool) => tool.id)).toEqual([
"kilo_memory_recall",
@@ -426,6 +432,7 @@ describe("kilocode tool registry indexing", () => {
"agent_manager_models",
"agent_manager",
"notify_user",
"send_file",
])
process.env["KILO_CLIENT"] = "desktop"
@@ -435,6 +442,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_save",
"recall",
"notify_user",
"send_file",
])
process.env["KILO_CLIENT"] = "run"
@@ -444,6 +452,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_save",
"recall",
"notify_user",
"send_file",
])
process.env["KILO_CLIENT"] = "acp"
@@ -453,6 +462,7 @@ describe("kilocode tool registry indexing", () => {
"kilo_memory_save",
"recall",
"notify_user",
"send_file",
])
} finally {
if (prev === undefined) delete process.env["KILO_CLIENT"]
@@ -56,6 +56,7 @@ function infos() {
process: info("background_process"),
image: info("generate_image"),
notify: info("notify_user"),
send: info("send_file"),
notebookRead: info("notebook_read"),
notebookEdit: info("notebook_edit"),
notebookExecute: info("notebook_execute"),
@@ -0,0 +1,541 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@/agent/agent"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import { KiloToolRegistry } from "@/kilocode/tool/registry"
import { SendFileTool } from "@/kilocode/tool/send-file"
import { MessageID, SessionID } from "@/session/schema"
import * as Truncate from "@/tool/truncate"
import type { Tool } from "@/tool/tool"
import { InstanceRef } from "@/effect/instance-ref"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { realpathSync } from "node:fs"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const agentInfo = {
name: "code",
mode: "primary",
options: {},
permission: {},
} as Agent.Info
const agents = Agent.Service.of({
get: () => Effect.succeed(agentInfo),
list: () => Effect.succeed([agentInfo]),
defaultInfo: () => Effect.succeed(agentInfo),
defaultAgent: () => Effect.succeed("code"),
requirementStatus: () =>
Effect.succeed({
agent: "code",
directory: "",
enabled: false,
state: "ready",
skills: [],
mcps: [],
vscode_extensions: [],
}),
guardRequirements: () => Effect.void,
generate: () => Effect.succeed({ identifier: "code", whenToUse: "", systemPrompt: "" }),
})
const truncate = Truncate.Service.of({
cleanup: () => Effect.void,
write: () => Effect.succeed(""),
output: (text) => Effect.succeed({ content: text as string, truncated: false }),
limits: () => Effect.succeed({ maxLines: Truncate.MAX_LINES, maxBytes: Truncate.MAX_BYTES }),
})
const ctx: Tool.Context = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "call_test",
agent: "code",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
const status = spyOn(KiloSessions, "remoteStatus")
beforeEach(() => {
status.mockReturnValue({ enabled: true, connected: true })
})
afterEach(() => {
status.mockReset()
})
function runSendTool(params: { readonly path: string }, dir: string) {
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
return Effect.runPromise(
Effect.gen(function* () {
const result = yield* SendFileTool
const tool = yield* result.init()
return yield* tool.execute(params, ctx)
}).pipe(Effect.provide(layer)),
)
}
async function tmpdir() {
const d = await fs.mkdtemp(path.join(os.tmpdir(), "send-file-test-"))
return fs.realpath(d)
}
describe("send_file tool", () => {
test("is only available while remote is connected", () => {
const tool = { id: "send_file" } as Tool.Def
status.mockReturnValue({ enabled: false, connected: false })
expect(KiloToolRegistry.available(tool, agentInfo)).toBe(false)
status.mockReturnValue({ enabled: true, connected: false })
expect(KiloToolRegistry.available(tool, agentInfo)).toBe(false)
status.mockReturnValue({ enabled: true, connected: true })
expect(KiloToolRegistry.available(tool, agentInfo)).toBe(true)
})
test("returns unavailable when not connected", async () => {
status.mockReturnValue({ enabled: true, connected: false })
const dir = await tmpdir()
try {
await fs.writeFile(path.join(dir, "test.txt"), "hello")
const result = await runSendTool({ path: "test.txt" }, dir)
expect(result.title).toBe("Send file failed")
expect(result.output).toContain("not connected")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("sends a file with mime attachment field and base64 round-trip", async () => {
const dir = await tmpdir()
try {
const content = "hello world"
await fs.writeFile(path.join(dir, "hello.txt"), content)
const result = await runSendTool({ path: "hello.txt" }, dir)
expect(result.title).toBe("Sent file: hello.txt")
expect(result.output).toContain("hello.txt")
expect(result.output).toContain("delivered to the user")
expect(result.attachments).toHaveLength(1)
const att = result.attachments![0]
expect(att.type).toBe("file")
expect(att.mime).toBe("text/plain")
expect(att.filename).toBe("hello.txt")
expect(att.url).toStartWith("data:text/plain;base64,")
// Verify base64 round-trip
const prefix = "data:text/plain;base64,"
const b64 = att.url!.slice(prefix.length)
const decoded = Buffer.from(b64, "base64").toString("utf-8")
expect(decoded).toBe(content)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("filename is always basename, never a full path", async () => {
const dir = await tmpdir()
try {
await fs.mkdir(path.join(dir, "sub"), { recursive: true })
const content = Buffer.from([0x89, 0x50, 0x4e, 0x47])
await fs.writeFile(path.join(dir, "sub", "deep.png"), content)
const result = await runSendTool({ path: "sub/deep.png" }, dir)
expect(result.title).toBe("Sent file: deep.png")
expect(result.attachments).toHaveLength(1)
expect(result.attachments![0].filename).toBe("deep.png")
// The sniff should detect PNG from magic bytes
expect(result.attachments![0].mime).toBe("image/png")
expect(result.attachments![0].url).toStartWith("data:image/png;base64,")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("sniffs correct MIME for a file with wrong extension", async () => {
const dir = await tmpdir()
try {
// Actually just test a real PNG — KiloReadObject opens by path,
// the sniffAttachmentMime looks at magic bytes
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0])
await fs.writeFile(path.join(dir, "secret.dat"), png)
const result = await runSendTool({ path: "secret.dat" }, dir)
expect(result.attachments![0].mime).toBe("image/png")
expect(result.attachments![0].filename).toBe("secret.dat")
expect(result.attachments![0].url).toStartWith("data:image/png;base64,")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
// kilocode_change start — send_file now authorizes missing files with external_directory + read
// before returning a structured fail() result, matching the read.ts security sequence.
test("authorizes missing file before returning fail result", async () => {
const dir = await tmpdir()
try {
const asks: any[] = []
const askCtx: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
asks.push(req)
}),
}
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: "nope.txt" }, askCtx)
}).pipe(Effect.provide(layer)),
)
expect(result.title).toBe("Send file failed")
expect(result.output).toContain("File not found")
expect(result.output).toContain("nope.txt")
// Authorization must run before the failure: expect read permission.
// external_directory is only required for paths outside worktree —
// a missing file inside worktree only triggers read permission.
const read = asks.find((a: any) => a.permission === "read")
expect(read).toBeDefined()
expect(read?.always).toEqual(["*"])
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
// kilocode_change end
// kilocode_change start — send_file now authorizes directories before returning a
// structured fail() result.
test("authorizes directory before returning fail result", async () => {
const dir = await tmpdir()
try {
await fs.mkdir(path.join(dir, "mydir"))
const asks: any[] = []
const askCtx: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
asks.push(req)
}),
}
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: "mydir" }, askCtx)
}).pipe(Effect.provide(layer)),
)
expect(result.title).toBe("Send file failed")
expect(result.output).toContain("is a directory")
// Authorization must run before the failure.
const ext = asks.find((a: any) => a.permission === "external_directory")
expect(ext).toBeUndefined() // inside worktree
const read = asks.find((a: any) => a.permission === "read")
expect(read).toBeDefined()
expect(read?.always).toEqual(["*"])
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
// kilocode_change end
test("rejects file larger than 4 MiB before reading", async () => {
const dir = await tmpdir()
try {
const big = path.join(dir, "big.bin")
// Create a sparse file > 4 MiB without writing all bytes
const handle = await fs.open(big, "w")
await handle.truncate(5 * 1024 * 1024)
await handle.close()
const result = await runSendTool({ path: "big.bin" }, dir)
expect(result.title).toBe("Send file too large")
expect(result.output).toContain("exceeds the 4 MiB limit")
expect(result.output).toContain("workspace path")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("asks for external_directory and read permissions when file is outside workspace", async () => {
const dir = await tmpdir()
const outside = await tmpdir()
try {
const outsideFile = path.join(outside, "outside.txt")
await fs.writeFile(outsideFile, "secret")
const asks: any[] = []
const askCtx: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
asks.push(req)
}),
}
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: outsideFile }, askCtx)
}).pipe(Effect.provide(layer)),
)
const ext = asks.find((a: any) => a.permission === "external_directory")
expect(ext).toBeDefined()
expect(ext?.patterns).toBeDefined()
const read = asks.find((a: any) => a.permission === "read")
expect(read).toBeDefined()
expect(read?.patterns).toBeDefined()
expect(read?.always).toEqual(["*"])
// Patterns must be non-empty and relative to worktree. The exact content
// depends on filesystem layout (symlinks may produce ../ segments), but
// every pattern must be a valid relative path — never empty or ".".
for (const p of read.patterns) {
expect(p.length).toBeGreaterThan(0)
expect(p).not.toBe(".")
}
} finally {
await fs.rm(dir, { recursive: true, force: true })
await fs.rm(outside, { recursive: true, force: true })
}
})
test("read permission patterns work with relative-path params inside worktree", async () => {
const dir = await tmpdir()
try {
await fs.mkdir(path.join(dir, "sub"), { recursive: true })
await fs.writeFile(path.join(dir, "sub", "hello.txt"), "hello")
const asks: any[] = []
const askCtx: Tool.Context = {
...ctx,
ask: (req) =>
Effect.sync(() => {
asks.push(req)
}),
}
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: "sub/hello.txt" }, askCtx)
}).pipe(Effect.provide(layer)),
)
// File inside worktree: no external_directory needed
const ext = asks.find((a: any) => a.permission === "external_directory")
expect(ext).toBeUndefined()
// Read permission is still required
const read = asks.find((a: any) => a.permission === "read")
expect(read).toBeDefined()
expect(read.patterns.length).toBeGreaterThan(0)
expect(read.always).toEqual(["*"])
// Verify the tool actually sent the file
expect(result.title).toBe("Sent file: hello.txt")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("reference-root bypass skips external_directory ask", async () => {
const dir = await tmpdir()
const refDir = await tmpdir()
try {
const refFile = path.join(refDir, "inner.txt")
await fs.writeFile(refFile, "ref-content")
const asks: any[] = []
const askCtx: Tool.Context = {
...ctx,
extra: { referenceRoot: refDir },
ask: (req) =>
Effect.sync(() => {
asks.push(req)
}),
}
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: refFile }, askCtx)
}).pipe(Effect.provide(layer)),
)
// A reference-root file outside the worktree bypasses external_directory.
const ext = asks.find((a: any) => a.permission === "external_directory")
expect(ext).toBeUndefined()
// But read permission is still required
const read = asks.find((a: any) => a.permission === "read")
expect(read).toBeDefined()
} finally {
await fs.rm(dir, { recursive: true, force: true })
await fs.rm(refDir, { recursive: true, force: true })
}
})
test("registers with id and has description in registry", async () => {
const dir = await tmpdir()
try {
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, fsService),
)
const result = await Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return { id: info.id, description: tool.description }
}).pipe(Effect.provide(layer)),
)
expect(result.id).toBe("send_file")
expect(result.description).toContain("Send a file from the local machine")
expect(result.description).toContain("Do NOT use this tool")
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
test("included in extra() list", () => {
const tool = { id: "send_file" } as Tool.Def
const extra = KiloToolRegistry.extra(
{
codebase: tool,
recall: tool,
managerModels: tool,
memory: tool,
save: tool,
manager: tool,
process: tool,
image: tool,
notify: { id: "notify_user" } as Tool.Def,
send: tool,
},
{},
)
const ids = extra.map((t) => t.id)
expect(ids).toContain("send_file")
})
test("non-NotFound stat failure propagates as an error", async () => {
const dir = await tmpdir()
try {
const permError: any = new Error("Permission denied")
permError.code = "EPERM"
permError.reason = { _tag: "PermissionDenied" }
const failingFs = FSUtil.Service.of({
stat: () => Effect.fail(permError) as any,
realPath: (candidate: string) =>
Effect.try({
try: () => realpathSync(candidate),
catch: (cause) => cause,
}),
} as unknown as FSUtil.Interface)
const layer = Layer.mergeAll(
Layer.succeed(InstanceRef, { directory: dir, worktree: dir, project: {} as any }),
Layer.succeed(Agent.Service, agents),
Layer.succeed(Truncate.Service, truncate),
Layer.succeed(FSUtil.Service, failingFs),
)
const promise = Effect.runPromise(
Effect.gen(function* () {
const info = yield* SendFileTool
const tool = yield* info.init()
return yield* tool.execute({ path: "anything.txt" }, ctx)
}).pipe(Effect.provide(layer)),
)
await expect(promise).rejects.toMatchObject({ code: "EPERM" })
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
})
// kilocode_change start — fsService mock now includes stat + realPath for the
// missing/directory authorization sequence that runs before KiloReadObject.file().
// stat only fabricates NotFound for ENOENT; other errors propagate.
const fsService = FSUtil.Service.of({
stat: (candidate: string) =>
Effect.tryPromise({
try: async () => {
const info = await fs.stat(candidate)
return { type: info.isFile() ? "File" : info.isDirectory() ? "Directory" : "Other" }
},
catch: (cause) => {
if (
cause != null &&
typeof cause === "object" &&
"code" in cause &&
(cause as NodeJS.ErrnoException).code === "ENOENT"
) {
const err = new Error() as any
err.reason = { _tag: "NotFound" }
return err
}
return cause
},
}) as any,
realPath: (candidate: string) =>
Effect.try({
try: () => realpathSync(candidate),
catch: (cause) => cause,
}),
} as FSUtil.Interface)
// kilocode_change end
@@ -494,6 +494,66 @@ describe("session.message-v2.toModelMessage", () => {
})
})
// kilocode_change start — send_file delivery attachments must not be replayed to the model
test("strips send_file delivery attachments from model context", async () => {
const userID = "m-user-sendfile"
const assistantID = "m-assistant-sendfile"
const input: SessionV1.WithParts[] = [
{
info: userInfo(userID),
parts: [
{
...basePart(userID, "u1-sendfile"),
type: "text",
text: "send me the log",
},
] as SessionV1.Part[],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1-sendfile"),
type: "tool",
callID: "call-sendfile-1",
tool: "send_file",
state: {
status: "completed",
input: { path: "/tmp/example.log" },
output: "File example.log (50 bytes, text/plain) delivered to the user's Kilo app.",
title: "Sent file: example.log",
metadata: {},
time: { start: 0, end: 1 },
attachments: [
{
...basePart(assistantID, "file-sendfile-1"),
type: "file",
mime: "text/plain",
filename: "example.log",
url: "data:text/plain;base64,aGVsbG8=",
},
],
},
},
] as SessionV1.Part[],
},
]
const result = await MessageV2.toModelMessages(input, model)
// There should be a tool-result but NO media attachment in the output
expect(result).toHaveLength(3)
expect(result[2].role).toBe("tool")
const toolContent = result[2].content[0] as any
expect(toolContent.toolName).toBe("send_file")
// Output should be plain text — no attachments replayed to the model
expect(toolContent.output).toStrictEqual({
type: "text",
value: "File example.log (50 bytes, text/plain) delivered to the user's Kilo app.",
})
})
// kilocode_change end
test("moves bedrock pdf tool-result media into a separate user message", async () => {
const bedrockModel: Provider.Model = {
...model,
@@ -1209,3 +1209,94 @@ itFragmentFailure.live("session.processor effect tests flush partial v2 fragment
{ config: cfg },
),
)
// kilocode_change start — send_file delivery attachments must skip image normalization.
// An image near the 4 MiB tool cap base64-encodes to ~5.5 MiB, exceeding the 5 MiB
// normalization limit. If normalized, the attachment would be omitted or rewritten
// after send_file reports success. The processor must preserve send_file attachments
// byte-for-byte.
const sendFileDeliveryLLM = Layer.succeed(
LLM.Service,
LLM.Service.of({
stream: () => {
const largeBase64 = "x".repeat(6 * 1024 * 1024) // 6 MiB exceeds 5 MiB MAX_BASE64_BYTES
const attachment = {
type: "file" as const,
id: PartID.ascending(),
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
mime: "image/png",
filename: "big.png",
url: `data:image/png;base64,${largeBase64}`,
}
return Stream.make(
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolInputStart({ id: "call-1", name: "send_file" }),
LLMEvent.toolInputEnd({ id: "call-1", name: "send_file" }),
LLMEvent.toolCall({ id: "call-1", name: "send_file", input: { path: "big.png" }, providerExecuted: true }),
LLMEvent.toolResult({
id: "call-1",
name: "send_file",
result: { type: "json", value: { output: "delivered", attachments: [attachment] } },
output: { structured: { output: "delivered", attachments: [attachment] }, content: [] },
providerExecuted: true,
}),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
)
},
}),
)
const sendFileDeliveryEnv = LayerNode.buildLayer(LayerNode.group([root, LayerNode.make(TestLLMServer.layer, [])]), {
replacements: [...replacements, LayerNode.replace(LLM.node, sendFileDeliveryLLM)],
})
const itSendFileDelivery = testEffect(sendFileDeliveryEnv)
itSendFileDelivery.live("session.processor preserves send_file delivery attachments without normalization", () =>
provideTmpdirServer(
({ dir }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
const chat = yield* session.create({})
const parent = yield* user(chat.id, "send file")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "send file" }],
tools: {},
})
const parts = yield* MessageV2.parts(msg.id)
const toolPart = parts.find(
(part): part is Extract<SessionV1.Part, { type: "tool" }> => part.type === "tool" && part.tool === "send_file",
)
if (!toolPart || toolPart.state.status !== "completed") {
return yield* Effect.fail(new Error("expected completed send_file tool part"))
}
expect(toolPart.state.output).not.toContain("omitted")
expect(toolPart.state.attachments).toHaveLength(1)
expect(toolPart.state.attachments?.[0]).toMatchObject({
mime: "image/png",
filename: "big.png",
url: `data:image/png;base64,${"x".repeat(6 * 1024 * 1024)}`,
})
}),
{ config: (url: string) => providerCfg(url) },
),
)
// kilocode_change end
@@ -0,0 +1,66 @@
import type { RGBA } from "@opentui/core"
import { Show } from "solid-js"
import type { PermissionProvenance } from "@/kilocode/permission/provenance"
import type { ToolState } from "@kilocode/sdk/v2"
/** `state.metadata` off any tool state, including the pending variant that lacks the field. */
export function stateMetadata(state: ToolState | undefined) {
return state && "metadata" in state ? state.metadata : undefined
}
const SOURCES = ["agent", "global", "project", "yolo", "session", "manual", "default"] as const
/** Read the approval/denial provenance off a tool part's metadata, if present. */
export function toolApprovalFrom(metadata: Record<string, unknown> | undefined) {
const value = metadata?.approval
if (!value || typeof value !== "object") return undefined
const approval = value as PermissionProvenance.Approval
return (SOURCES as readonly string[]).includes(approval.source) ? approval : undefined
}
function sourceLabel(approval: PermissionProvenance.Approval): string | undefined {
switch (approval.source) {
case "agent":
return approval.agent ? `by the ${approval.agent} agent` : "by the agent"
case "global":
return "by your global config"
case "project":
return "by the project config"
case "yolo":
return "by auto-approve (YOLO) mode"
case "session":
return "by a session auto-approve rule"
case "default":
return "by default"
default:
return undefined
}
}
/** A short "why" line describing an auto-approval or denial, for the TUI's plain-text rows. */
export function describeApproval(metadata: Record<string, unknown> | undefined): string | undefined {
const approval = toolApprovalFrom(metadata)
if (!approval) return undefined
const manual = approval.source === "manual"
const decision = manual ? "approved by you" : approval.rule?.action === "deny" ? "denied" : "auto-approved"
if (manual) return decision
const source = sourceLabel(approval)
const rule = approval.rule
// The catch-all "*"/"*" rule carries no useful detail; let the source alone explain it.
const ruleText =
rule && !(rule.permission === "*" && rule.pattern === "*") ? ` (matched ${rule.permission} \`${rule.pattern}\`)` : ""
return source ? `${decision} ${source}${ruleText}` : decision
}
/**
* The muted "why" annotation appended inline after a tool's title/summary text, matching the
* `RoutedModelMeta.Badge` convention. Rendered on the header line (not after the tool's own
* output) so it reads as metadata about the call rather than part of the output itself.
*/
export function ApprovalBadge(props: { note: string | undefined; color?: RGBA }) {
return (
<Show when={props.note}>
<span style={{ fg: props.color }}> · {props.note}</span>
</Show>
)
}
+16 -1
View File
@@ -63,6 +63,7 @@ import { Toast, useToast } from "../../ui/toast"
import { useKV } from "../../context/kv.tsx"
import stripAnsi from "strip-ansi"
import { usePromptRef } from "../../context/prompt"
import { ApprovalBadge, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change
import { useEpilogue } from "../../context/epilogue"
import { normalizePath } from "../../util/path"
import { PermissionPrompt } from "./permission"
@@ -2204,6 +2205,8 @@ function InlineTool(props: {
const failed = createMemo(() => Boolean(error() && !denied()))
const clickable = createMemo(() => Boolean(props.onClick || failed()))
// kilocode_change - explain why the call was auto-approved or denied
const approvalNote = createMemo(() => describeApproval(stateMetadata(props.part.state)))
const fg = createMemo(() => {
if (props.color) return props.color
if (permission()) return theme.warning
@@ -2228,6 +2231,8 @@ function InlineTool(props: {
failure={props.failure}
spinner={props.spinner}
separate={props.separate}
note={approvalNote()} // kilocode_change
noteColor={theme.textMuted} // kilocode_change
onMouseOver={() => clickable() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
@@ -2258,6 +2263,8 @@ export function InlineToolRow(props: {
failure?: string
spinner?: boolean
separate?: boolean
note?: string // kilocode_change - why the call was auto-approved or denied
noteColor?: RGBA // kilocode_change
children: JSX.Element
onMouseOver?: () => void
onMouseOut?: () => void
@@ -2310,6 +2317,8 @@ export function InlineToolRow(props: {
attributes={props.denied ? TextAttributes.STRIKETHROUGH : undefined}
>
{props.failed && !props.complete ? (props.failure ?? props.children) : props.children}
{/* kilocode_change - explain why the call was auto-approved or denied, inline on the header */}
<ApprovalBadge note={props.note} color={props.noteColor} />
</text>
</box>
</Show>
@@ -2330,11 +2339,14 @@ function BlockTool(props: {
onClick?: () => void
part?: ToolPart
spinner?: boolean
hideApproval?: boolean // kilocode_change - suppress the auto-approval note (e.g. todowrite)
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
// kilocode_change - explain why the call was auto-approved or denied
const approvalNote = createMemo(() => (props.hideApproval ? undefined : describeApproval(stateMetadata(props.part?.state))))
return (
<box
ref={(el: BoxRenderable) => alwaysSeparate.add(el)}
@@ -2361,6 +2373,8 @@ function BlockTool(props: {
{props.title}
{/* kilocode_change start */}
<RoutedModelMeta.View id={props.part?.id} />
{/* explain why the call was auto-approved or denied, inline on the title */}
<ApprovalBadge note={approvalNote()} color={theme.textMuted} />
{/* kilocode_change end */}
</text>
}
@@ -2825,7 +2839,8 @@ function TodoWrite(props: ToolProps) {
return (
<Switch>
<Match when={parseTodos(props.metadata.todos).length}>
<BlockTool title="# Todos" part={props.part}>
{/* kilocode_change - todo writes are orchestration, not a mutating action to explain */}
<BlockTool title="# Todos" part={props.part} hideApproval>
<box>
<For each={todos()}>{(todo) => <TodoItem status={todo.status} content={todo.content} />}</For>
</box>