From 63d35d06cf03a27766b47f02a37008706ec98540 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 30 Jul 2026 18:04:39 +0200 Subject: [PATCH 01/24] feat(cli): record why a tool call was denied on its metadata Auto-approval provenance was only recorded on the metadata of allowed tool calls (state.metadata.approval), so denied calls had no structured explanation of which rule/config/agent denied them. Since 'kilo export' serializes state.metadata verbatim into the JSON session log, denials showed up with no provenance at all. Add PermissionProvenance.classifyDenial, which reads the deciding deny rule off a DeniedError's tagged ruleset and classifies it the same way approvals are classified. Wire it into SessionTools' ctx.ask via Effect.tapErrorTag so denials are recorded before the tool call fails, reusing the existing carryApproval/failToolCall preservation so the metadata survives onto the final error state. --- .../src/kilocode/permission/provenance.ts | 13 ++++++++ packages/opencode/src/session/tools.ts | 13 ++++++++ .../kilocode/sandbox/session-tools.test.ts | 31 +++++++++++++++++-- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 50e4309bdbd..abf9647d72e 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -98,4 +98,17 @@ 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`) but is always the tagged ruleset `askPermission` + * built, filtered to the request's permission. The last `deny` rule in it is the one that decided. + */ + export function classifyDenial(input: { ruleset: unknown; agent: string; origins: Origins }): Approval { + const rule = Array.isArray(input.ruleset) + ? (input.ruleset as Permission.Rule[]).findLast((rule) => rule.action === "deny") + : undefined + return classify({ rule, agent: input.agent, origins: input.origins }) + } } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f6d78654cbe..f33c8fc627d 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -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,18 @@ 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, + agent: input.agent.name, + origins: permissionOrigins, + }), + }, + }), + ), Effect.asVoid, Effect.orDie, ), diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 39331ff436d..a41a244ceca 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -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 }[] = []) { 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 }[] = [] + const deniedRuleset = [{ 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: deniedRuleset })), + }), + ) + 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" }, + }) + }), +) From 8e515dd6f12c112a1c61de3d46b80dbce14ad585 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 30 Jul 2026 18:04:55 +0200 Subject: [PATCH 02/24] feat(tui): show why a tool call was auto-approved or denied Ports the auto-approval provenance explanation already shown in kilo-ui/vscode to the TUI. Adds a Kilo-owned tool-approval.tsx with a plain-text description helper (describeApproval) and a shared ApprovalNote row component, then wires a single call into InlineTool/ InlineToolRow (Shell, Read, Grep, Glob, WebFetch, etc.) and BlockTool (Write, Edit, ApplyPatch, Task), showing a muted line under completed/ failed tool calls. Todo writes are excluded via a hideApproval prop, mirroring the kilo-ui behavior that treats them as orchestration rather than an auditable action. --- packages/tui/src/kilocode/tool-approval.tsx | 64 +++++++++++++++++++++ packages/tui/src/routes/session/index.tsx | 21 ++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 packages/tui/src/kilocode/tool-approval.tsx diff --git a/packages/tui/src/kilocode/tool-approval.tsx b/packages/tui/src/kilocode/tool-approval.tsx new file mode 100644 index 00000000000..fefc09419bb --- /dev/null +++ b/packages/tui/src/kilocode/tool-approval.tsx @@ -0,0 +1,64 @@ +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 | 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 | 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" row rendered under a completed/failed inline or block tool. */ +export function ApprovalNote(props: { note: string | undefined; color?: RGBA; paddingLeft: number }) { + return ( + + + {props.note} + + + ) +} diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index fc1d28b8d73..1b41ba61d30 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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 { ApprovalNote, 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 @@ -2320,6 +2327,12 @@ export function InlineToolRow(props: { {props.error} + {/* kilocode_change - explain why the call was auto-approved or denied */} + ) } @@ -2330,11 +2343,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 ( alwaysSeparate.add(el)} @@ -2368,6 +2384,8 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} + {/* kilocode_change - explain why the call was auto-approved or denied */} + {error()} @@ -2825,7 +2843,8 @@ function TodoWrite(props: ToolProps) { return ( - + {/* kilocode_change - todo writes are orchestration, not a mutating action to explain */} + {(todo) => } From 6b27a26f929f570275e26529189b4d2fc3c392cf Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 13:32:40 +0200 Subject: [PATCH 03/24] docs: add changeset for TUI auto-approval/denial explanation --- .changeset/explain-tool-auto-approval-tui.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/explain-tool-auto-approval-tui.md diff --git a/.changeset/explain-tool-auto-approval-tui.md b/.changeset/explain-tool-auto-approval-tui.md new file mode 100644 index 00000000000..df3e2f5cd03 --- /dev/null +++ b/.changeset/explain-tool-auto-approval-tui.md @@ -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. From abe17f1f9d0d20d25aede46b6c2299865d14a40b Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:31:49 +0200 Subject: [PATCH 04/24] fix(tui): move the tool approval/denial note back onto the header line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note was appended after the tool's own output (or, in an interim revert, on its own line above it), which either looked like part of the output or was visually noisier than desired. Render it inline on the header/title line instead, matching the existing RoutedModelMeta badge convention (' · note'), so it reads unambiguously as metadata about the call rather than output. --- packages/tui/src/kilocode/tool-approval.tsx | 12 +++++++----- packages/tui/src/routes/session/index.tsx | 14 +++++--------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/tui/src/kilocode/tool-approval.tsx b/packages/tui/src/kilocode/tool-approval.tsx index fefc09419bb..36719d83dc0 100644 --- a/packages/tui/src/kilocode/tool-approval.tsx +++ b/packages/tui/src/kilocode/tool-approval.tsx @@ -52,13 +52,15 @@ export function describeApproval(metadata: Record | undefined): return source ? `${decision} ${source}${ruleText}` : decision } -/** The muted "why" row rendered under a completed/failed inline or block tool. */ -export function ApprovalNote(props: { note: string | undefined; color?: RGBA; paddingLeft: number }) { +/** + * 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 ( - - {props.note} - + · {props.note} ) } diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1b41ba61d30..7cc3f9150f2 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -63,7 +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 { ApprovalNote, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change +import { ApprovalBadge, describeApproval, stateMetadata } from "../../kilocode/tool-approval" // kilocode_change import { useEpilogue } from "../../context/epilogue" import { normalizePath } from "../../util/path" import { PermissionPrompt } from "./permission" @@ -2317,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 */} + @@ -2327,12 +2329,6 @@ export function InlineToolRow(props: { {props.error} - {/* kilocode_change - explain why the call was auto-approved or denied */} - ) } @@ -2377,6 +2373,8 @@ function BlockTool(props: { {props.title} {/* kilocode_change start */} + {/* explain why the call was auto-approved or denied, inline on the title */} + {/* kilocode_change end */} } @@ -2384,8 +2382,6 @@ function BlockTool(props: { {props.title.replace(/^# /, "")} {props.children} - {/* kilocode_change - explain why the call was auto-approved or denied */} - {error()} From 5f3b57b97198fd84b35b54770831c06e4bb1f02c Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:32:10 +0200 Subject: [PATCH 05/24] fix(cli): attribute tool call denials to the rule that actually decided them DeniedError.ruleset only carried the deny-permission subset, so PermissionProvenance.classifyDenial had to guess the deciding rule via findLast(action === "deny"). With two deny rules for different patterns under the same permission (e.g. bash: { "git push *": deny, "rm -rf *": deny }), this could attribute a denial to whichever rule sorted last instead of the one that actually matched the request. Permission.ask now embeds the exact rule resolve()/evaluate() matched against the request's pattern directly on the error (ruleset: { rule, matches }), so classifyDenial reads it instead of re-deriving it. Some denials carry no rule at all (e.g. the headless-subagent policy denial), where classify({ rule: undefined }) reports the same { source: "default" } shape as the *approval* fallback -- silently rendering a refusal as an auto-approval in the TUI and kilo export. classifyDenial now synthesizes an explicit deny rule for the request's permission/pattern in that case, so rule.action always reflects the real outcome. Adds test/kilocode/permission/deny-provenance.test.ts covering both regressions against the real Permission.Service, and updates the existing session-tools.test.ts denial fixture to the new ruleset shape. --- .../src/kilocode/permission/provenance.ts | 32 +++++-- packages/opencode/src/permission/index.ts | 18 +++- packages/opencode/src/session/tools.ts | 2 + .../permission/deny-provenance.test.ts | 91 +++++++++++++++++++ .../kilocode/sandbox/session-tools.test.ts | 4 +- 5 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/test/kilocode/permission/deny-provenance.test.ts diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index abf9647d72e..499e07f8662 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -102,13 +102,33 @@ export namespace PermissionProvenance { /** * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. * - * `DeniedError.ruleset` is untyped (`Schema.Any`) but is always the tagged ruleset `askPermission` - * built, filtered to the request's permission. The last `deny` rule in it is the one that decided. + * `DeniedError.ruleset` is untyped (`Schema.Any`); `Permission.ask` shapes it as + * `{ rule, matches }`, where `rule` is the exact rule `resolve()` matched against the + * request's pattern (via `Wildcard.match`), not merely the last `deny` rule for the + * permission. 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. + * + * Some denials carry no `rule` at all — e.g. the headless-subagent policy denial in + * `Permission.ask`, which isn't decided by any rule. `classify({ rule: undefined })` reports + * `{ source: "default" }`, the exact same shape as the *approval* fallback for "no rule + * matched", so a denial with no rule would otherwise render (and export) as an auto-approval. + * Synthesize a `deny` rule for the request's permission/pattern in that case so `rule.action` + * always reflects the real outcome. */ - export function classifyDenial(input: { ruleset: unknown; agent: string; origins: Origins }): Approval { - const rule = Array.isArray(input.ruleset) - ? (input.ruleset as Permission.Rule[]).findLast((rule) => rule.action === "deny") - : undefined + export function classifyDenial(input: { + ruleset: unknown + permission: string + patterns: readonly string[] + agent: string + origins: Origins + }): Approval { + const denial = input.ruleset as { rule?: Permission.Rule } | undefined + const rule = denial?.rule ?? { + permission: input.permission, + pattern: input.patterns[0] ?? "*", + action: "deny" as const, + } return classify({ rule, agent: input.agent, origins: input.origins }) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 9ce38c952fc..efec951dfea 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -226,14 +226,21 @@ export const layer = Layer.effect( for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) - // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials - if (veto(request.permission, pattern, hardRuleset)) { - return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) + // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials. + // Report the exact hard rule that matched this pattern (not just the deny-permission + // subset) so provenance attributes the denial to the right rule, not just any deny rule. + const hardRule = hardRuleset && ExternalDirectoryPermission.evaluate(request.permission, pattern, hardRuleset) + if (hardRule?.action === "deny") { + return yield* new DeniedError({ + ruleset: { rule: hardRule, matches: subset(request.permission, hardRuleset ?? []) }, + }) } // kilocode_change end if (rule.action === "deny") { + // kilocode_change - carry the exact matched `rule` (not just the deny-permission subset) + // so provenance can attribute the denial to the pattern that actually decided it. return yield* new DeniedError({ - ruleset: subset(request.permission, ruleset), // kilocode_change + ruleset: { rule, matches: subset(request.permission, ruleset) }, }) } // kilocode_change start - skill shell forces a prompt instead of honoring an allow/auto-approve rule @@ -255,7 +262,8 @@ export const layer = Layer.effect( // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) { - return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) }) + // no single rule decided this — it's a headless policy denial, not a ruleset match + return yield* new DeniedError({ ruleset: { matches: subset(request.permission, ruleset) } }) } // kilocode_change end diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f33c8fc627d..98bb1a11e4b 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -89,6 +89,8 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { metadata: { approval: PermissionProvenance.classifyDenial({ ruleset: err.ruleset, + permission: req.permission, + patterns: req.patterns, agent: input.agent.name, origins: permissionOrigins, }), diff --git a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts new file mode 100644 index 00000000000..c04bce534c3 --- /dev/null +++ b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts @@ -0,0 +1,91 @@ +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[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) { + 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 rule in the carried ruleset is still reported as denied, not as an ambiguous default approval", () => { + // Some denials carry no `rule` at all (e.g. the headless-subagent policy denial in + // Permission.ask, which isn't decided by any ruleset match). 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: { matches: [] }, + 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" }) + }) +}) diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index a41a244ceca..d1eedce06ee 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -361,11 +361,11 @@ 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 }[] = [] - const deniedRuleset = [{ permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const }] + 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: deniedRuleset })), + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: { rule: deniedRule, matches: [deniedRule] } })), }), ) const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) From c56aad9d6c1861a843ab7502bd6b4e6aea04d4ec Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 31 Jul 2026 16:45:29 +0200 Subject: [PATCH 06/24] refactor(cli): shrink the denial-provenance shared-file diff in permission/index.ts Applies the kilocode-merge-minimizer skill to the prior fix. The hard-veto and headless-subagent DeniedError sites are reverted to their exact pre-fix shape -- neither carries a specific rule anyway, so wrapping their ruleset in a { rule, matches } object added shared upstream diff for no benefit. Only the main deny path (which already had the deciding rule in scope) still changes, and now passes the bare rule instead of a wrapper object, shrinking that hunk from a multi-line block to a single-line swap. PermissionProvenance.classifyDenial now duck-types ruleset as a possible bare Permission.Rule (checking action === "deny" and a string pattern) instead of expecting a { rule } wrapper, so it still reads the main deny path's rule directly while falling back to a synthesized deny rule for the other paths, exactly as before. Net shared-file diff across permission/index.ts, session/tools.ts, and the TUI's routes/session/index.tsx for this whole feature is now 9 insertions / 12 deletions, down from ~50+ lines. --- .../src/kilocode/permission/provenance.ts | 34 +++++++++---------- packages/opencode/src/permission/index.ts | 21 ++++-------- .../permission/deny-provenance.test.ts | 15 ++++---- .../kilocode/sandbox/session-tools.test.ts | 2 +- 4 files changed, 31 insertions(+), 41 deletions(-) diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 499e07f8662..73f7fec4e0f 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -102,19 +102,18 @@ export namespace PermissionProvenance { /** * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. * - * `DeniedError.ruleset` is untyped (`Schema.Any`); `Permission.ask` shapes it as - * `{ rule, matches }`, where `rule` is the exact rule `resolve()` matched against the - * request's pattern (via `Wildcard.match`), not merely the last `deny` rule for the - * permission. 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. + * `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. * - * Some denials carry no `rule` at all — e.g. the headless-subagent policy denial in - * `Permission.ask`, which isn't decided by any rule. `classify({ rule: undefined })` reports - * `{ source: "default" }`, the exact same shape as the *approval* fallback for "no rule - * matched", so a denial with no rule would otherwise render (and export) as an auto-approval. - * Synthesize a `deny` rule for the request's permission/pattern in that case so `rule.action` - * always reflects the real outcome. + * 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 @@ -123,12 +122,11 @@ export namespace PermissionProvenance { agent: string origins: Origins }): Approval { - const denial = input.ruleset as { rule?: Permission.Rule } | undefined - const rule = denial?.rule ?? { - permission: input.permission, - pattern: input.patterns[0] ?? "*", - action: "deny" as const, - } + const candidate = input.ruleset as Partial | 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 }) } } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index efec951dfea..8ffd4c53b3f 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -226,22 +226,14 @@ export const layer = Layer.effect( for (const pattern of request.patterns) { const rule = resolve(request.permission, pattern, ruleset, approved, local) // kilocode_change — include session-scoped rules yield* Effect.logInfo("evaluated", { permission: request.permission, pattern, action: rule }) - // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials. - // Report the exact hard rule that matched this pattern (not just the deny-permission - // subset) so provenance attributes the denial to the right rule, not just any deny rule. - const hardRule = hardRuleset && ExternalDirectoryPermission.evaluate(request.permission, pattern, hardRuleset) - if (hardRule?.action === "deny") { - return yield* new DeniedError({ - ruleset: { rule: hardRule, matches: subset(request.permission, hardRuleset ?? []) }, - }) + // kilocode_change start — saved/session approvals cannot override hard Ask/Plan denials + if (veto(request.permission, pattern, hardRuleset)) { + return yield* new DeniedError({ ruleset: subset(request.permission, hardRuleset ?? []) }) } // kilocode_change end if (rule.action === "deny") { - // kilocode_change - carry the exact matched `rule` (not just the deny-permission subset) - // so provenance can attribute the denial to the pattern that actually decided it. - return yield* new DeniedError({ - ruleset: { rule, matches: subset(request.permission, ruleset) }, - }) + // 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) { @@ -262,8 +254,7 @@ export const layer = Layer.effect( // kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903) if (yield* KiloHeadless.denies(request.sessionID).pipe(Effect.provideService(Database.Service, database))) { - // no single rule decided this — it's a headless policy denial, not a ruleset match - return yield* new DeniedError({ ruleset: { matches: subset(request.permission, ruleset) } }) + return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) }) } // kilocode_change end diff --git a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts index c04bce534c3..67613e954b5 100644 --- a/packages/opencode/test/kilocode/permission/deny-provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/deny-provenance.test.ts @@ -72,14 +72,15 @@ describe("Permission.ask denial provenance", () => { ), ) - test("a denial with no rule in the carried ruleset is still reported as denied, not as an ambiguous default approval", () => { - // Some denials carry no `rule` at all (e.g. the headless-subagent policy denial in - // Permission.ask, which isn't decided by any ruleset match). 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. + 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: { matches: [] }, + ruleset: [{ permission: "bash", pattern: "*", action: "ask" as const }], permission: "bash", patterns: ["rm -rf /"], agent: "build", diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index d1eedce06ee..b2fa9a670e2 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -365,7 +365,7 @@ it.live("records why a denied tool call was refused on the tool part's metadata" const overrides = Layer.mergeAll( TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }), Layer.mock(Permission.Service)({ - ask: () => Effect.fail(new Permission.DeniedError({ ruleset: { rule: deniedRule, matches: [deniedRule] } })), + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRule })), }), ) const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) From 8be33032b906f27c0d883212354f75eeb7044f35 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 3 Aug 2026 10:18:00 +0200 Subject: [PATCH 07/24] fix(cli): omit persona from generated names --- .changeset/quiet-metadata-generators.md | 5 + .../opencode/src/kilocode/system-prompt.ts | 4 + packages/opencode/src/session/llm/request.ts | 12 +- .../test/kilocode/session-llm-request.test.ts | 127 ++++++++++++++++++ 4 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-metadata-generators.md create mode 100644 packages/opencode/test/kilocode/session-llm-request.test.ts diff --git a/.changeset/quiet-metadata-generators.md b/.changeset/quiet-metadata-generators.md new file mode 100644 index 00000000000..67bb87bcbd9 --- /dev/null +++ b/.changeset/quiet-metadata-generators.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep Kilo's persona out of generated conversation titles and Agent Manager branch names. diff --git a/packages/opencode/src/kilocode/system-prompt.ts b/packages/opencode/src/kilocode/system-prompt.ts index 9e2e393471c..7733f592ba6 100644 --- a/packages/opencode/src/kilocode/system-prompt.ts +++ b/packages/opencode/src/kilocode/system-prompt.ts @@ -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 persona(agent: string) { + return agent !== "title" && agent !== "branch-name" + } + export function environment(input: { ctx: InstanceContext; model: Provider.Model; editor?: EditorContext }) { return [ [ diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 8e8bc254123..07b77e3e708 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -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, source: Record | export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) { const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth" + const persona = KilocodeSystemPrompt.persona(input.agent.name) // kilocode_change const system = [ [ // kilocode_change start - soul defines core identity and personality - ...(isOpenaiOauth ? [] : [SystemPrompt.soul()]), + ...(isOpenaiOauth || !persona ? [] : [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 = [...(persona ? [SystemPrompt.soul()] : []), ...system].join("\n") + // kilocode_change end + } const messages = isOpenaiOauth || input.isWorkflow diff --git a/packages/opencode/test/kilocode/session-llm-request.test.ts b/packages/opencode/test/kilocode/session-llm-request.test.ts new file mode 100644 index 00000000000..cef2db3a4e2 --- /dev/null +++ b/packages/opencode/test/kilocode/session-llm-request.test.ts @@ -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()) + }) +}) From 0cfd301745d9483d6a506206731d2331c0e231af Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 3 Aug 2026 10:31:22 +0200 Subject: [PATCH 08/24] refactor(cli): clarify generated-name persona policy --- packages/opencode/src/agent/agent.ts | 3 ++- packages/opencode/src/kilocode/branch-name.ts | 3 ++- .../opencode/src/kilocode/system-prompt.ts | 9 +++++++-- packages/opencode/src/session/llm/request.ts | 6 +++--- .../test/kilocode/session-llm-request.test.ts | 18 +++++++++++------- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index b3df2c7ae52..20aa52ef5aa 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -32,6 +32,7 @@ import * as KiloAgent from "@/kilocode/agent" import { RuntimeFlags } from "@/effect/runtime-flags" import * as AgentRequirements from "@/kilocode/agent-requirements" import * as KiloReference from "@/kilocode/reference" +import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { MCP } from "@/mcp" // kilocode_change end import { ProviderV2 } from "@opencode-ai/core/provider" @@ -309,7 +310,7 @@ export const layer = Layer.effect( options: {}, }, title: { - name: "title", + name: KilocodeSystemPrompt.agents.title, // kilocode_change mode: "primary", options: {}, native: true, diff --git a/packages/opencode/src/kilocode/branch-name.ts b/packages/opencode/src/kilocode/branch-name.ts index a6bb008bcf3..cd5d2daa447 100644 --- a/packages/opencode/src/kilocode/branch-name.ts +++ b/packages/opencode/src/kilocode/branch-name.ts @@ -6,6 +6,7 @@ import { Provider } from "@/provider/provider" import { LLM } from "@/session/llm" import { MessageV2 } from "@/session/message-v2" import { MessageID, SessionID } from "@/session/schema" +import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { Effect } from "effect" const LIMIT = 4 @@ -85,7 +86,7 @@ export const generate = Effect.fn("BranchName.generate")(function* (input: { const model = (yield* provider.getSmallModel(ref.providerID)) ?? (yield* provider.getModel(ref.providerID, ref.modelID)) const agent: Agent.Info = { - name: "branch-name", + name: KilocodeSystemPrompt.agents.branch, mode: "primary", hidden: true, options: {}, diff --git a/packages/opencode/src/kilocode/system-prompt.ts b/packages/opencode/src/kilocode/system-prompt.ts index 7733f592ba6..a1471116019 100644 --- a/packages/opencode/src/kilocode/system-prompt.ts +++ b/packages/opencode/src/kilocode/system-prompt.ts @@ -13,8 +13,13 @@ import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "kilocode.system-prompt" }) export namespace KilocodeSystemPrompt { - export function persona(agent: string) { - return agent !== "title" && agent !== "branch-name" + export const agents = { + title: "title", + branch: "branch-name", + } as const + + export function shouldIncludePersona(agent: string) { + return agent !== agents.title && agent !== agents.branch } export function environment(input: { ctx: InstanceContext; model: Provider.Model; editor?: EditorContext }) { diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 07b77e3e708..4bb5b81e70e 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -68,11 +68,11 @@ const mergeOptions = (target: Record, source: Record | export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) { const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth" - const persona = KilocodeSystemPrompt.persona(input.agent.name) // kilocode_change + const includePersona = KilocodeSystemPrompt.shouldIncludePersona(input.agent.name) // kilocode_change const system = [ [ // kilocode_change start - soul defines core identity and personality - ...(isOpenaiOauth || !persona ? [] : [SystemPrompt.soul()]), + ...(isOpenaiOauth || !includePersona ? [] : [SystemPrompt.soul()]), // kilocode_change end ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), ...input.system, @@ -119,7 +119,7 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre } if (isOpenaiOauth) { // kilocode_change start - prepend soul to instructions - options.instructions = [...(persona ? [SystemPrompt.soul()] : []), ...system].join("\n") + options.instructions = [...(includePersona ? [SystemPrompt.soul()] : []), ...system].join("\n") // kilocode_change end } diff --git a/packages/opencode/test/kilocode/session-llm-request.test.ts b/packages/opencode/test/kilocode/session-llm-request.test.ts index cef2db3a4e2..2919ca14d9f 100644 --- a/packages/opencode/test/kilocode/session-llm-request.test.ts +++ b/packages/opencode/test/kilocode/session-llm-request.test.ts @@ -9,6 +9,7 @@ import type { Auth } from "@/auth" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { LLMRequestPrep } from "@/session/llm/request" import { MessageID, SessionID } from "@/session/schema" import { SystemPrompt } from "@/session/system" @@ -101,7 +102,7 @@ async function prepare(name: string, oauth = false) { } describe("Kilo persona in generated metadata requests", () => { - test.each(["title", "branch-name"])("omits the persona for %s generation", async (name) => { + test.each(Object.values(KilocodeSystemPrompt.agents))("omits the persona for %s generation", async (name) => { const result = await prepare(name) expect(result.system[0]).toContain(`${name} generation prompt`) @@ -109,13 +110,16 @@ describe("Kilo persona in generated metadata requests", () => { 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) + test.each(Object.values(KilocodeSystemPrompt.agents))( + "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()) - }) + 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") From 323f096e11512549c7a87a570380ee732e053006 Mon Sep 17 00:00:00 2001 From: Christiaan Arnoldus Date: Mon, 3 Aug 2026 10:53:57 +0200 Subject: [PATCH 09/24] refactor(cli): revert shared metadata identifiers --- packages/opencode/src/agent/agent.ts | 3 +-- packages/opencode/src/kilocode/branch-name.ts | 3 +-- .../opencode/src/kilocode/system-prompt.ts | 7 +------ .../test/kilocode/session-llm-request.test.ts | 18 +++++++----------- 4 files changed, 10 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 20aa52ef5aa..b3df2c7ae52 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -32,7 +32,6 @@ import * as KiloAgent from "@/kilocode/agent" import { RuntimeFlags } from "@/effect/runtime-flags" import * as AgentRequirements from "@/kilocode/agent-requirements" import * as KiloReference from "@/kilocode/reference" -import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { MCP } from "@/mcp" // kilocode_change end import { ProviderV2 } from "@opencode-ai/core/provider" @@ -310,7 +309,7 @@ export const layer = Layer.effect( options: {}, }, title: { - name: KilocodeSystemPrompt.agents.title, // kilocode_change + name: "title", mode: "primary", options: {}, native: true, diff --git a/packages/opencode/src/kilocode/branch-name.ts b/packages/opencode/src/kilocode/branch-name.ts index cd5d2daa447..a6bb008bcf3 100644 --- a/packages/opencode/src/kilocode/branch-name.ts +++ b/packages/opencode/src/kilocode/branch-name.ts @@ -6,7 +6,6 @@ import { Provider } from "@/provider/provider" import { LLM } from "@/session/llm" import { MessageV2 } from "@/session/message-v2" import { MessageID, SessionID } from "@/session/schema" -import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { Effect } from "effect" const LIMIT = 4 @@ -86,7 +85,7 @@ export const generate = Effect.fn("BranchName.generate")(function* (input: { const model = (yield* provider.getSmallModel(ref.providerID)) ?? (yield* provider.getModel(ref.providerID, ref.modelID)) const agent: Agent.Info = { - name: KilocodeSystemPrompt.agents.branch, + name: "branch-name", mode: "primary", hidden: true, options: {}, diff --git a/packages/opencode/src/kilocode/system-prompt.ts b/packages/opencode/src/kilocode/system-prompt.ts index a1471116019..d10d34d5458 100644 --- a/packages/opencode/src/kilocode/system-prompt.ts +++ b/packages/opencode/src/kilocode/system-prompt.ts @@ -13,13 +13,8 @@ import * as Log from "@opencode-ai/core/util/log" const log = Log.create({ service: "kilocode.system-prompt" }) export namespace KilocodeSystemPrompt { - export const agents = { - title: "title", - branch: "branch-name", - } as const - export function shouldIncludePersona(agent: string) { - return agent !== agents.title && agent !== agents.branch + return agent !== "title" && agent !== "branch-name" } export function environment(input: { ctx: InstanceContext; model: Provider.Model; editor?: EditorContext }) { diff --git a/packages/opencode/test/kilocode/session-llm-request.test.ts b/packages/opencode/test/kilocode/session-llm-request.test.ts index 2919ca14d9f..cef2db3a4e2 100644 --- a/packages/opencode/test/kilocode/session-llm-request.test.ts +++ b/packages/opencode/test/kilocode/session-llm-request.test.ts @@ -9,7 +9,6 @@ import type { Auth } from "@/auth" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" -import { KilocodeSystemPrompt } from "@/kilocode/system-prompt" import { LLMRequestPrep } from "@/session/llm/request" import { MessageID, SessionID } from "@/session/schema" import { SystemPrompt } from "@/session/system" @@ -102,7 +101,7 @@ async function prepare(name: string, oauth = false) { } describe("Kilo persona in generated metadata requests", () => { - test.each(Object.values(KilocodeSystemPrompt.agents))("omits the persona for %s generation", async (name) => { + 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`) @@ -110,16 +109,13 @@ describe("Kilo persona in generated metadata requests", () => { expect(result.system[0]).not.toContain(SystemPrompt.soul()) }) - test.each(Object.values(KilocodeSystemPrompt.agents))( - "omits the persona from OpenAI OAuth %s generation", - async (name) => { - const result = await prepare(name, true) + 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()) - }, - ) + 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") From 20d1648e7a10f981fde09fc4d5e9de5c89b1dda8 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 3 Aug 2026 11:41:52 +0200 Subject: [PATCH 10/24] fix(agent-manager): route mode shortcuts through modal --- .../agent-manager-modal-mode-shortcut.md | 5 ++ .../unit/agent-manager-mode-router.test.ts | 36 +++++++++++++ .../tests/unit/session-model-store.test.ts | 10 ++++ .../agent-manager/AgentManagerApp.tsx | 15 ++++-- .../agent-manager/NewWorktreeDialog.tsx | 53 +++++++++++++++---- .../webview-ui/agent-manager/ProjectList.tsx | 3 ++ .../webview-ui/agent-manager/mode-router.ts | 26 +++++++++ .../webview-ui/src/context/session.tsx | 22 ++++++++ .../webview-ui/src/stories/StoryProviders.tsx | 3 ++ .../src/stories/agent-manager.stories.tsx | 2 + 10 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 .changeset/agent-manager-modal-mode-shortcut.md create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-mode-router.test.ts create mode 100644 packages/kilo-vscode/webview-ui/agent-manager/mode-router.ts diff --git a/.changeset/agent-manager-modal-mode-shortcut.md b/.changeset/agent-manager-modal-mode-shortcut.md new file mode 100644 index 00000000000..4efdaa9bc43 --- /dev/null +++ b/.changeset/agent-manager-modal-mode-shortcut.md @@ -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. diff --git a/packages/kilo-vscode/tests/unit/agent-manager-mode-router.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-mode-router.test.ts new file mode 100644 index 00000000000..50f1560b34b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-mode-router.test.ts @@ -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]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 01f862b9bd3..bed7b4c6a09 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -149,6 +149,16 @@ describe("per-session model selection", () => { }) describe("per-mode model memory", () => { + it("resolves a specific mode's remembered model without a session", () => { + let store = emptyStore() + const e = env() + + const result = applyModel(store, "ask", gpt, undefined) + store = { ...store, ...result } + + expect(getSelected(store, e, undefined, "ask")).toEqual(gpt) + }) + it("applyModel in a session writes only to sessionOverrides", () => { const store = emptyStore() const result = applyModel(store, "code", claude, "session-a") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 979679f24ba..5b5849734a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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>(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(() => dialog.close()} defaultBaseBranch={repoDefaultBranch()} />) + dialog.show(() => ( + 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)} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index a91193c725a..164f3c7a9d8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -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>() const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "") const [versions, setVersions] = createSignal(1) - const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(session.configModel()) + const initialAgent = session.selectedAgent() + const initialModel = session.configModelForAgent(initialAgent) + const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(initialModel) const [compareMode, setCompareMode] = createSignal(false) const [modelAllocations, setModelAllocations] = createSignal(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(session.currentVariant()) + const [variant, setVariant] = createSignal(session.variantForAgent(initialAgent, initialModel)) const [sandbox, setSandbox] = createSignal() const [sandboxDefault, setSandboxDefault] = createSignal() const [sandboxOverride, setSandboxOverride] = createSignal() @@ -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 @@ -611,7 +646,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran