From 73ab363f9a1592721d4ce4b92d1a083b7bc8176b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 06:17:02 +0300 Subject: [PATCH 01/11] fix(cli): include subagent costs in session total (#6321) When a session spawned subagents via the task tool, the displayed cost reflected only the parent's own LLM steps. Propagate each subagent's total up to the invoking assistant message so every UI sums the full tree. --- .changeset/fix-subagent-cost-propagation.md | 5 + .../src/kilocode/session/cost-propagation.ts | 40 +++ packages/opencode/src/session/prompt.ts | 19 ++ packages/opencode/src/tool/task.ts | 14 +- .../test/session/prompt-effect.test.ts | 59 +++++ packages/opencode/test/tool/task.test.ts | 230 +++++++++++++++++- 6 files changed, 360 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-subagent-cost-propagation.md create mode 100644 packages/opencode/src/kilocode/session/cost-propagation.ts diff --git a/.changeset/fix-subagent-cost-propagation.md b/.changeset/fix-subagent-cost-propagation.md new file mode 100644 index 0000000000..3f31f223c6 --- /dev/null +++ b/.changeset/fix-subagent-cost-propagation.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix session cost display missing subagent costs. The TUI footer, sidebar, web context panel, and ACP usage reports now include the cost of every subagent the session spawned, including nested ones. diff --git a/packages/opencode/src/kilocode/session/cost-propagation.ts b/packages/opencode/src/kilocode/session/cost-propagation.ts new file mode 100644 index 0000000000..2aa16bca72 --- /dev/null +++ b/packages/opencode/src/kilocode/session/cost-propagation.ts @@ -0,0 +1,40 @@ +// kilocode_change - new file +import { Effect } from "effect" +import { Session } from "@/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionID, MessageID } from "@/session/schema" + +export namespace KiloCostPropagation { + /** + * Total assistant-message cost in a session. Because each subagent propagates + * its own total into the parent assistant message when it finishes, this sum + * already reflects descendant sessions recursively — no tree walk needed. + */ + export const childCost = Effect.fn("KiloCostPropagation.childCost")(function* ( + sessions: Session.Interface, + id: SessionID, + ) { + const msgs = yield* sessions.messages({ sessionID: id }) + return msgs.reduce((sum, m) => sum + (m.info.role === "assistant" ? m.info.cost : 0), 0) + }) + + /** + * Add `amount` to the given parent assistant message's cost. No-op when + * `amount` is non-positive or the target is not an assistant message. + * + * Caller must guarantee serial access to the parent message — concurrent + * calls against the same `(sid, mid)` are not atomic (read-modify-write). + */ + export const propagate = Effect.fn("KiloCostPropagation.propagate")(function* ( + sessions: Session.Interface, + sid: SessionID, + mid: MessageID, + amount: number, + ) { + if (!(amount > 0)) return + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: sid, messageID: mid })) + if (parent.info.role !== "assistant") return + parent.info.cost += amount + yield* sessions.updateMessage(parent.info) + }) +} diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0281aaf281..7581ca7cba 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change +import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change import z from "zod" @@ -598,6 +599,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the let error: Error | undefined const taskAbort = new AbortController() + // kilocode_change start - shared reader for the child session id written by task.ts ctx.metadata (#6321) + const childID = () => { + const meta = part.state.status !== "pending" ? part.state.metadata : undefined + return (meta as { sessionId?: string } | undefined)?.sessionId + } + // kilocode_change end const result = yield* taskTool .execute(taskArgs, { agent: task.agent, @@ -636,6 +643,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the taskAbort.abort() assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() + // kilocode_change start - propagate partial subagent cost on cancel (#6321) + const cid = childID() + if (cid) { + assistantMessage.cost = yield* KiloCostPropagation.childCost(sessions, SessionID.make(cid)) + } + // kilocode_change end yield* sessions.updateMessage(assistantMessage) if (part.state.status === "running") { yield* sessions.updatePart({ @@ -668,6 +681,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() + // kilocode_change start - include subagent total cost on the wrapper message (#6321) + const cid = result?.metadata?.sessionId ?? childID() + if (cid) { + assistantMessage.cost = yield* KiloCostPropagation.childCost(sessions, SessionID.make(cid)) + } + // kilocode_change end yield* sessions.updateMessage(assistantMessage) if (result && part.state.status === "running") { diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 2b678f04a3..69fb644331 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" import { Effect } from "effect" import { KiloTask } from "../kilocode/tool/task" // kilocode_change +import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -135,9 +136,12 @@ export const TaskTool = Tool.define( } return yield* Effect.acquireUseRelease( - Effect.sync(() => { + // kilocode_change start - snapshot child cost so we propagate only the delta on resume (#6321) + Effect.gen(function* () { ctx.abort.addEventListener("abort", cancel) + return yield* KiloCostPropagation.childCost(sessions, nextSession.id) }), + // kilocode_change end () => Effect.gen(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) @@ -172,10 +176,14 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { + // kilocode_change start - propagate subagent cost delta to parent on every exit path (#6321) + (costBefore) => + Effect.gen(function* () { ctx.abort.removeEventListener("abort", cancel) + const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id) + yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore) }), + // kilocode_change end ) }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 1cf6abb779..719b8712e7 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -812,6 +812,65 @@ it.live( 30_000, ) +// kilocode_change start - handleSubtask propagates child session cost to wrapper (#6321) +it.live( + "handleSubtask propagates subagent cost to wrapper message", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const registry = yield* ToolRegistry.Service + const { task } = yield* registry.named() + const original = task.execute + // Simulate task tool: create a child session, persist an assistant with cost, return metadata. + task.execute = (_args, ctx) => + Effect.gen(function* () { + const child = yield* sessions.create({ parentID: ctx.sessionID, title: "subagent" }) + const childAssistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: ctx.messageID, + sessionID: child.id, + mode: "general", + agent: "general", + cost: 0.42, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now(), completed: Date.now() }, + finish: "stop", + } + yield* sessions.updateMessage(childAssistant) + yield* ctx.metadata({ + title: "done", + metadata: { sessionId: child.id, model: ref }, + }) + return { title: "done", metadata: { sessionId: child.id, model: ref }, output: "done" } + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) + + const chat = yield* sessions.create({ title: "Pinned" }) + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + // The loop continues past handleSubtask into a normal LLM step; provide one response to exit. + yield* llm.text("wrapped") + + yield* prompt.loop({ sessionID: chat.id }) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const wrapper = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(wrapper?.info.role).toBe("assistant") + if (!wrapper || wrapper.info.role !== "assistant") return + expect(wrapper.info.cost).toBeCloseTo(0.42, 6) + }), + { git: true, config: providerCfg }, + ), + 30_000, +) +// kilocode_change end + it.live( "cancel with queued callers resolves all cleanly", () => diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b85279df75..5b264d43ee 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -7,7 +7,7 @@ import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" // kilocode_change - SessionID used by cost propagation tests import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "../../src/tool" @@ -64,17 +64,28 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { return { chat, assistant } }) -function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { +// kilocode_change start - stub signature + prompt body extended to persist assistant cost for propagation tests +function stubOps(opts?: { + onPrompt?: (input: SessionPrompt.PromptInput) => void + text?: string + sessions?: Session.Interface + childCost?: number +}): TaskPromptOps { return { cancel() {}, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => - Effect.sync(() => { + Effect.gen(function* () { opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done") + const rep = reply(input, opts?.text ?? "done") + if (opts?.sessions && opts?.childCost != null) { + yield* opts.sessions.updateMessage({ ...rep.info, cost: opts.childCost }) + } + return rep }), } } +// kilocode_change end function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { const id = MessageID.ascending() @@ -395,3 +406,214 @@ describe("tool.task", () => { ), ) }) + +// kilocode_change start - subagent cost propagation coverage (#6321) +const assistantCost = Effect.fn("TaskToolTest.assistantCost")(function* (sessionID: string) { + const sessions = yield* Session.Service + const msgs = yield* sessions.messages({ sessionID: SessionID.make(sessionID) }) + return msgs.reduce((sum, m) => sum + (m.info.role === "assistant" ? m.info.cost : 0), 0) +}) + +describe("tool.task cost propagation", () => { + it.live("propagates subagent cost to parent assistant message", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps({ sessions, childCost: 0.25 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + expect(parent.info.role).toBe("assistant") + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBeCloseTo(0.25, 6) + }), + ), + ) + + it.live("propagates recursively through nested subagent costs", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + // Pre-create a child with its own assistant already bearing a grandchild cost. + const child = yield* sessions.create({ parentID: chat.id, title: "grandchild-accumulated" }) + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: assistant.id, + sessionID: child.id, + mode: "build", + agent: "general", + cost: 0.4, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + }) + + const tool = yield* TaskTool + const def = yield* tool.init() + // Resuming into the same child via task_id and the stub tacks on another 0.15. + const promptOps = stubOps({ sessions, childCost: 0.15 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + // Only the delta since the start of this invocation propagates. + expect(parent.info.cost).toBeCloseTo(0.15, 6) + // Child session keeps the full cumulative total (0.4 pre-existing + 0.15 this run). + expect(yield* assistantCost(child.id)).toBeCloseTo(0.55, 6) + }), + ), + ) + + it.live("resumed task_id only propagates the delta", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "resume target" }) + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: assistant.id, + sessionID: child.id, + mode: "build", + agent: "general", + cost: 0.1, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + }) + + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps({ sessions, childCost: 0.05 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "continue investigation", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + // Delta-only: only the 0.05 from this run, not 0.15 including the pre-existing 0.10. + expect(parent.info.cost).toBeCloseTo(0.05, 6) + }), + ), + ) + + it.live("propagates partial cost on abort", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const abort = new AbortController() + // Stub that persists a partial cost, then aborts — mimics interrupted run after tokens billed. + const ops: TaskPromptOps = { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.gen(function* () { + const info: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: "general", + agent: "general", + cost: 0.07, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* sessions.updateMessage(info) + abort.abort() + return yield* Effect.interrupt + }), + } + + yield* def + .execute( + { + description: "partial", + prompt: "will abort", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBeCloseTo(0.07, 6) + }), + ), + ) +}) +// kilocode_change end From ae0c43779167b7d7d88d983cd147b60b782ed644 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 07:26:09 +0300 Subject: [PATCH 02/11] fix(cli): serialize concurrent subagent cost propagation --- .../src/kilocode/session/cost-propagation.ts | 41 ++++++-- .../test/kilocode/cost-propagation.test.ts | 94 +++++++++++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/kilocode/cost-propagation.test.ts diff --git a/packages/opencode/src/kilocode/session/cost-propagation.ts b/packages/opencode/src/kilocode/session/cost-propagation.ts index 2aa16bca72..107b56d56a 100644 --- a/packages/opencode/src/kilocode/session/cost-propagation.ts +++ b/packages/opencode/src/kilocode/session/cost-propagation.ts @@ -5,6 +5,28 @@ import { MessageV2 } from "@/session/message-v2" import { SessionID, MessageID } from "@/session/schema" export namespace KiloCostPropagation { + /** + * Per-key promise chain that serializes concurrent `propagate` calls against + * the same parent message. Prevents lost updates when the LLM launches + * several `task` tool calls in parallel (each release stage races to + * read-modify-write the same parent cost field). + */ + const locks = new Map>() + + function acquire(key: string): Promise<() => void> { + const prev = locks.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise((r) => (release = r)) + const chain = prev.catch(() => {}).then(() => current) + locks.set(key, chain) + return prev + .catch(() => {}) + .then(() => () => { + release() + if (locks.get(key) === chain) locks.delete(key) + }) + } + /** * Total assistant-message cost in a session. Because each subagent propagates * its own total into the parent assistant message when it finishes, this sum @@ -22,8 +44,8 @@ export namespace KiloCostPropagation { * Add `amount` to the given parent assistant message's cost. No-op when * `amount` is non-positive or the target is not an assistant message. * - * Caller must guarantee serial access to the parent message — concurrent - * calls against the same `(sid, mid)` are not atomic (read-modify-write). + * Concurrent calls against the same parent are serialized internally so the + * read-modify-write cannot lose updates when subagents complete in parallel. */ export const propagate = Effect.fn("KiloCostPropagation.propagate")(function* ( sessions: Session.Interface, @@ -32,9 +54,16 @@ export namespace KiloCostPropagation { amount: number, ) { if (!(amount > 0)) return - const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: sid, messageID: mid })) - if (parent.info.role !== "assistant") return - parent.info.cost += amount - yield* sessions.updateMessage(parent.info) + yield* Effect.acquireUseRelease( + Effect.promise(() => acquire(`${sid}:${mid}`)), + () => + Effect.gen(function* () { + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: sid, messageID: mid })) + if (parent.info.role !== "assistant") return + parent.info.cost += amount + yield* sessions.updateMessage(parent.info) + }), + (release) => Effect.sync(() => release()), + ) }) } diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts new file mode 100644 index 0000000000..343be816d0 --- /dev/null +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -0,0 +1,94 @@ +// Verifies KiloCostPropagation.propagate() serializes concurrent writes to +// the same parent assistant message. Without the internal lock, parallel +// subagent completions race on read-modify-write and lose deltas (#6321). + +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Bus } from "../../src/bus" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { KiloCostPropagation } from "../../src/kilocode/session/cost-propagation" +import { Instance } from "../../src/project/instance" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID } from "../../src/session/schema" +import { Log } from "../../src/util" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer)) + +const seed = Effect.fn("CostPropagationTest.seed")(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "parent" }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* sessions.updateMessage(assistant) + return { chat, assistant } +}) + +describe("KiloCostPropagation.propagate", () => { + it.live("sums deltas correctly under parallel execution", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const deltas = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28] + yield* Effect.all( + deltas.map((d) => KiloCostPropagation.propagate(sessions, chat.id, assistant.id, d)), + { concurrency: "unbounded" }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + expect(parent.info.role).toBe("assistant") + if (parent.info.role !== "assistant") return + const total = deltas.reduce((a, b) => a + b, 0) + expect(parent.info.cost).toBeCloseTo(total, 6) + }), + ), + ) + + it.live("is a no-op when amount is non-positive", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, 0) + yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, -1.5) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBe(0) + }), + ), + ) +}) From 418fe78f5db2c6c746f8cf8448369a64dd6d3a08 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 18:05:45 +0300 Subject: [PATCH 03/11] fix(cli): sync processor cost after subagent propagation finish-step and cleanup previously re-wrote the parent assistant message with a stale in-memory cost, clobbering the subagent cost written by task.ts during tool execution. Refresh from DB before each write so the propagated cost is preserved across steps. Verified live via side-by-side `kilo serve` vs dev `bun serve` with 2 parallel subagents; parent now shows own LLM + children, matching real spend. --- packages/opencode/src/session/processor.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 6a825cd3fc..1dd7c2ded2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -410,6 +410,14 @@ export const layer: Layer.Layer< }) // kilocode_change end ctx.assistantMessage.finish = value.finishReason + // kilocode_change start - capture any subagent cost propagated by tool calls during this step (#6321) + const fresh = yield* Effect.sync(() => + MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }), + ) + if (fresh.info.role === "assistant" && fresh.info.cost > ctx.assistantMessage.cost) { + ctx.assistantMessage.cost = fresh.info.cost + } + // kilocode_change end ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ @@ -567,6 +575,14 @@ export const layer: Layer.Layer< ctx.toolcalls = {} KiloSessionProcessor.guardEmptyToolCalls(ctx.assistantMessage, MessageV2.parts(ctx.assistantMessage.id)) // kilocode_change ctx.assistantMessage.time.completed = Date.now() + // kilocode_change start - reconcile cost with any subagent propagation written during tool calls (#6321) + const fresh = yield* Effect.sync(() => + MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }), + ) + if (fresh.info.role === "assistant" && fresh.info.cost > ctx.assistantMessage.cost) { + ctx.assistantMessage.cost = fresh.info.cost + } + // kilocode_change end yield* session.updateMessage(ctx.assistantMessage) }) From 195ef4e54fe798241ac7c2de198f51d99ef87da8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 19:06:08 +0300 Subject: [PATCH 04/11] fix(cli): exclude subagent sessions from stats aggregate The task tool propagates each child session's total cost into the parent's tool-wrapper assistant message. Counting both root and child sessions in `kilo stats` would double-count that contribution. Filter `getAllSessions()` to root sessions (parent_id IS NULL), matching how the TUI session list and web sidebar already treat child sessions. --- packages/opencode/src/cli/cmd/stats.ts | 7 +- .../test/kilocode/stats-subagent-cost.test.ts | 84 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/kilocode/stats-subagent-cost.test.ts diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 34af56ad7a..8c23e8b276 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -2,7 +2,7 @@ import type { Argv } from "yargs" import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" -import { Database } from "../../storage" +import { Database, isNull } from "../../storage" // kilocode_change - isNull for root session filter import { SessionTable } from "../../session/session.sql" import { Project } from "../../project" import { Instance } from "../../project/instance" @@ -89,7 +89,10 @@ async function getCurrentProject(): Promise { } async function getAllSessions(): Promise { - const rows = Database.use((db) => db.select().from(SessionTable).all()) + // kilocode_change start - exclude subagent (child) sessions; their cost is already propagated into the + // parent's tool-wrapper assistant message, so summing both would double-count (#6321) + const rows = Database.use((db) => db.select().from(SessionTable).where(isNull(SessionTable.parent_id)).all()) + // kilocode_change end return rows.map((row) => Session.fromRow(row)) } diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts new file mode 100644 index 0000000000..fd01af6bbf --- /dev/null +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -0,0 +1,84 @@ +// Verifies `kilo stats` does not double-count subagent cost. The task tool +// propagates each child session's total cost up to the parent's tool-wrapper +// assistant message. If stats summed every session indiscriminately, that +// propagated cost would appear in both the parent wrapper and the child's +// own messages. The aggregator is now filtered to root sessions (#6321). + +import { afterEach, describe, expect, test } from "bun:test" +import { aggregateSessionStats } from "../../src/cli/cmd/stats" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageID } from "../../src/session/schema" +import { Log } from "../../src/util" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +function assistant(sessionID: string, parentID: string, cost: number): MessageV2.Assistant { + return { + id: MessageID.ascending(), + role: "assistant", + parentID: parentID as any, + sessionID: sessionID as any, + mode: "build", + agent: "build", + cost, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } +} + +describe("stats subagent cost", () => { + test("totalCost excludes children whose cost was propagated into the parent", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parent = await Session.create({ title: "root" }) + const child = await Session.create({ parentID: parent.id, title: "subagent" }) + + // The parent's tool-wrapper assistant message shows the propagated total (own LLM + child). + const userMsg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: parent.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + } as any) + await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5)) + // The child session independently records its own LLM cost. + const childUser = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: child.id, + agent: "general", + model: ref, + time: { created: Date.now() }, + } as any) + await Session.updateMessage(assistant(child.id, childUser.id, 0.5)) + + const stats = await aggregateSessionStats() + // Without the fix, totalCost would be 1.5 + 0.5 = 2.0 (child counted twice). + // With the fix, only the parent (root) session contributes: 1.5 (which already + // includes the 0.5 propagated from the child). + expect(stats.totalCost).toBeCloseTo(1.5, 6) + expect(stats.totalSessions).toBe(1) + }, + }) + }) +}) From 1230a3af8b9f443873f62ec1fab941cc3c24a21a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 19:19:35 +0300 Subject: [PATCH 05/11] fix(cli): ignore deleted cost sync --- packages/opencode/src/session/processor.ts | 31 ++++---- ...session-processor-empty-tool-calls.test.ts | 71 +++++++++++++++++++ 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 1dd7c2ded2..3fe7b1c5d9 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -19,6 +19,7 @@ import type { Provider } from "@/provider" import { Question } from "@/question" import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change +import { NotFoundError } from "@/storage" // kilocode_change import { errorMessage } from "@/util/error" import { Log } from "@/util" import { isRecord } from "@/util/record" @@ -157,6 +158,22 @@ export const layer: Layer.Layer< return { call, part } }) + // kilocode_change start - tolerate deleted sessions during subagent cost reconciliation (#6321) + const reconcile = Effect.fn("SessionProcessor.reconcileCost")(function* () { + const fresh = yield* Effect.sync(() => { + try { + return MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }) + } catch (err) { + if (NotFoundError.isInstance(err)) return + throw err + } + }) + if (fresh?.info.role !== "assistant") return + if (fresh.info.cost <= ctx.assistantMessage.cost) return + ctx.assistantMessage.cost = fresh.info.cost + }) + // kilocode_change end + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( toolCallID: string, update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, @@ -411,12 +428,7 @@ export const layer: Layer.Layer< // kilocode_change end ctx.assistantMessage.finish = value.finishReason // kilocode_change start - capture any subagent cost propagated by tool calls during this step (#6321) - const fresh = yield* Effect.sync(() => - MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }), - ) - if (fresh.info.role === "assistant" && fresh.info.cost > ctx.assistantMessage.cost) { - ctx.assistantMessage.cost = fresh.info.cost - } + yield* reconcile() // kilocode_change end ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens @@ -576,12 +588,7 @@ export const layer: Layer.Layer< KiloSessionProcessor.guardEmptyToolCalls(ctx.assistantMessage, MessageV2.parts(ctx.assistantMessage.id)) // kilocode_change ctx.assistantMessage.time.completed = Date.now() // kilocode_change start - reconcile cost with any subagent propagation written during tool calls (#6321) - const fresh = yield* Effect.sync(() => - MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }), - ) - if (fresh.info.role === "assistant" && fresh.info.cost > ctx.assistantMessage.cost) { - ctx.assistantMessage.cost = fresh.info.cost - } + yield* reconcile() // kilocode_change end yield* session.updateMessage(ctx.assistantMessage) }) diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index b2e5b8e48e..276edaefa4 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -183,6 +183,77 @@ describe("session processor empty tool-calls", () => { ), ) + it.live("ignores deleted session during cost reconciliation", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const processors = yield* SessionProcessor.Service + const session = yield* Session.Service + + yield* test.reply( + { type: "start" }, + { type: "start-step" } as LLM.Event, + { + type: "finish-step", + finishReason: "stop", + usage: usage(), + providerMetadata: undefined, + } as LLM.Event, + { type: "finish" } as LLM.Event, + ) + + const chat = yield* session.create({}) + const parent = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "code", + model: ref, + time: { created: Date.now() }, + }) + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: chat.id, + parentID: parent.id, + mode: "code", + agent: "code", + path: { cwd: path.resolve(dir), root: path.resolve(dir) }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* session.updateMessage(msg) + + const mdl = model() + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + yield* session.remove(chat.id) + + const input: LLM.StreamInput = { + user: parent as MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: { name: "code", mode: "primary", permission: [], options: {} } as any, + system: [], + messages: [], + tools: {}, + } + + const result = yield* handle.process(input) + expect(result).toBe("continue") + expect(handle.message.error).toBeUndefined() + }), + { git: true }, + ), + ) + it.live("preserves tool-calls finish when tool parts exist", () => provideTmpdirInstance( (dir) => From bc90a50597ad7431733b1bc8faea1e3bb09c91ff Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 19:19:41 +0300 Subject: [PATCH 06/11] fix(cli): count subagent stats --- packages/opencode/src/cli/cmd/stats.ts | 21 ++++-- .../test/kilocode/stats-subagent-cost.test.ts | 69 +++++++++++++++---- 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 8c23e8b276..18f27a8f50 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,8 +1,9 @@ import type { Argv } from "yargs" +import type { MessageV2 } from "../../session/message-v2" // kilocode_change import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" -import { Database, isNull } from "../../storage" // kilocode_change - isNull for root session filter +import { Database } from "../../storage" import { SessionTable } from "../../session/session.sql" import { Project } from "../../project" import { Instance } from "../../project/instance" @@ -89,10 +90,7 @@ async function getCurrentProject(): Promise { } async function getAllSessions(): Promise { - // kilocode_change start - exclude subagent (child) sessions; their cost is already propagated into the - // parent's tool-wrapper assistant message, so summing both would double-count (#6321) - const rows = Database.use((db) => db.select().from(SessionTable).where(isNull(SessionTable.parent_id)).all()) - // kilocode_change end + const rows = Database.use((db) => db.select().from(SessionTable).all()) return rows.map((row) => Session.fromRow(row)) } @@ -196,7 +194,16 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 + // kilocode_change start - count propagated subagent cost once but keep child model stats (#6321) + const cost = (() => { + const parts = message.parts.filter( + (part: MessageV2.Part): part is MessageV2.StepFinishPart => part.type === "step-finish", + ) + if (parts.length === 0) return message.info.cost || 0 + return parts.reduce((sum: number, part: MessageV2.StepFinishPart) => sum + part.cost, 0) + })() + if (!session.parentID) sessionCost += message.info.cost || 0 + // kilocode_change end const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { @@ -207,7 +214,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } } sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 + sessionModelUsage[modelKey].cost += cost if (message.info.tokens) { sessionTokens.input += message.info.tokens.input || 0 diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts index fd01af6bbf..f4e5d31100 100644 --- a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -1,8 +1,7 @@ -// Verifies `kilo stats` does not double-count subagent cost. The task tool -// propagates each child session's total cost up to the parent's tool-wrapper -// assistant message. If stats summed every session indiscriminately, that -// propagated cost would appear in both the parent wrapper and the child's -// own messages. The aggregator is now filtered to root sessions (#6321). +// Verifies `kilo stats` does not double-count subagent cost while still +// including child-session messages, tokens, tools, and model usage. The task +// tool propagates each child session's total cost up to the parent's +// tool-wrapper assistant message (#6321). import { afterEach, describe, expect, test } from "bun:test" import { aggregateSessionStats } from "../../src/cli/cmd/stats" @@ -10,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2" import { Instance } from "../../src/project/instance" import { ProviderID, ModelID } from "../../src/provider/schema" import { Session } from "../../src/session" -import { MessageID } from "../../src/session/schema" +import { MessageID, PartID } from "../../src/session/schema" import { Log } from "../../src/util" import { tmpdir } from "../fixture/fixture" @@ -42,8 +41,40 @@ function assistant(sessionID: string, parentID: string, cost: number): MessageV2 } } +async function step(sessionID: string, messageID: string, cost: number) { + await Session.updatePart({ + id: PartID.ascending(), + messageID: messageID as any, + sessionID: sessionID as any, + type: "step-finish", + reason: "stop", + cost, + tokens: { total: 15, input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + }) +} + +async function tool(sessionID: string, messageID: string) { + const time = Date.now() + await Session.updatePart({ + id: PartID.ascending(), + messageID: messageID as any, + sessionID: sessionID as any, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "ok", + title: "bash", + metadata: {}, + time: { start: time, end: time }, + }, + }) +} + describe("stats subagent cost", () => { - test("totalCost excludes children whose cost was propagated into the parent", async () => { + test("counts child usage without double-counting propagated cost", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ directory: tmp.path, @@ -51,7 +82,6 @@ describe("stats subagent cost", () => { const parent = await Session.create({ title: "root" }) const child = await Session.create({ parentID: parent.id, title: "subagent" }) - // The parent's tool-wrapper assistant message shows the propagated total (own LLM + child). const userMsg = await Session.updateMessage({ id: MessageID.ascending(), role: "user", @@ -60,8 +90,9 @@ describe("stats subagent cost", () => { model: ref, time: { created: Date.now() }, } as any) - await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5)) - // The child session independently records its own LLM cost. + const parentMsg = await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5)) + await step(parent.id, parentMsg.id, 1) + const childUser = await Session.updateMessage({ id: MessageID.ascending(), role: "user", @@ -70,14 +101,22 @@ describe("stats subagent cost", () => { model: ref, time: { created: Date.now() }, } as any) - await Session.updateMessage(assistant(child.id, childUser.id, 0.5)) + const childMsg = await Session.updateMessage(assistant(child.id, childUser.id, 0.5)) + await step(child.id, childMsg.id, 0.5) + await tool(child.id, childMsg.id) const stats = await aggregateSessionStats() - // Without the fix, totalCost would be 1.5 + 0.5 = 2.0 (child counted twice). - // With the fix, only the parent (root) session contributes: 1.5 (which already - // includes the 0.5 propagated from the child). + const model = stats.modelUsage["test/test-model"]! expect(stats.totalCost).toBeCloseTo(1.5, 6) - expect(stats.totalSessions).toBe(1) + expect(stats.totalSessions).toBe(2) + expect(stats.totalMessages).toBe(4) + expect(stats.totalTokens.input).toBe(20) + expect(stats.totalTokens.output).toBe(10) + expect(stats.toolUsage.bash).toBe(1) + expect(model.messages).toBe(2) + expect(model.tokens.input).toBe(20) + expect(model.tokens.output).toBe(10) + expect(model.cost).toBeCloseTo(1.5, 6) }, }) }) From 7a9da3fd6195c867cff012f1cc0d4238d6aaf76c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 24 Apr 2026 19:20:25 +0300 Subject: [PATCH 07/11] chore(cli): mark stats cost line --- packages/opencode/src/cli/cmd/stats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 18f27a8f50..a52e35f060 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -214,7 +214,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } } sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += cost + sessionModelUsage[modelKey].cost += cost // kilocode_change if (message.info.tokens) { sessionTokens.input += message.info.tokens.input || 0 From 1eaf4091437b7e1336fa2558112885cf9b8d03f4 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 10:21:36 +0300 Subject: [PATCH 08/11] refactor(cli): simplify stats cost --- packages/opencode/src/cli/cmd/stats.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index a52e35f060..0242f72f35 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -1,5 +1,4 @@ import type { Argv } from "yargs" -import type { MessageV2 } from "../../session/message-v2" // kilocode_change import { cmd } from "./cmd" import { Session } from "../../session" import { bootstrap } from "../bootstrap" @@ -195,13 +194,8 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin for (const message of messages) { if (message.info.role === "assistant") { // kilocode_change start - count propagated subagent cost once but keep child model stats (#6321) - const cost = (() => { - const parts = message.parts.filter( - (part: MessageV2.Part): part is MessageV2.StepFinishPart => part.type === "step-finish", - ) - if (parts.length === 0) return message.info.cost || 0 - return parts.reduce((sum: number, part: MessageV2.StepFinishPart) => sum + part.cost, 0) - })() + const parts = message.parts.filter((part) => part.type === "step-finish") + const cost = parts.length ? parts.reduce((sum, part) => sum + part.cost, 0) : message.info.cost || 0 if (!session.parentID) sessionCost += message.info.cost || 0 // kilocode_change end From 16abe9679f57937318dd052d52c5bd51a168287c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Mon, 27 Apr 2026 13:11:31 +0300 Subject: [PATCH 09/11] chore(cli): fix type error after main merge Add missing 'variant' property to task tool metadata in prompt-effect test, required after upstream added model variant support to the task tool's ExecuteResult type. --- packages/opencode/test/session/prompt-effect.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 652a7e07eb..c2633dfb48 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -845,9 +845,9 @@ it.live( yield* sessions.updateMessage(childAssistant) yield* ctx.metadata({ title: "done", - metadata: { sessionId: child.id, model: ref }, + metadata: { sessionId: child.id, model: ref, variant: undefined }, }) - return { title: "done", metadata: { sessionId: child.id, model: ref }, output: "done" } + return { title: "done", metadata: { sessionId: child.id, model: ref, variant: undefined }, output: "done" } }) yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) From 87d1d9648753b150f9f2b0b9c412e6bc8af17e1c Mon Sep 17 00:00:00 2001 From: Scuttle Bot Date: Mon, 27 Apr 2026 06:41:43 -0400 Subject: [PATCH 10/11] docs: add @git-changes to VS Code context mentions (#9505) * docs: add @git-changes mention to VS Code context mentions page * Apply suggestion from @kilo-code-bot[bot] Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --------- Co-authored-by: scuttlebot Co-authored-by: Marius Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- .../pages/code-with-ai/agents/context-mentions.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index dd10ed38b0..3a0011661d 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -20,12 +20,13 @@ When you describe a task, the agent uses its tools — `read`, `grep`, `glob`, a Type `@` in the chat input to get autocomplete suggestions. You can mention: -| Mention | Description | Example | -| ------------ | ------------------------------------------- | --------------- | -| **File** | Attach a file's contents to your message | `@src/utils.ts` | -| **Terminal** | Include your active VS Code terminal output | `@terminal` | +| Mention | Description | Example | +| ---------------- | ----------------------------------------------------- | --------------- | +| **File** | Attach a file's contents to your message | `@src/utils.ts` | +| **Terminal** | Include your active VS Code terminal output | `@terminal` | +| **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` | -Selecting a suggestion inserts the mention and highlights it in the input. File contents and terminal output are attached as context when you send the message. +Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message. ### Drag and Drop From baf4bae91c76227c77b0cd5a300a981e9c35fd4f Mon Sep 17 00:00:00 2001 From: "hdcode.dev" Date: Mon, 27 Apr 2026 12:57:30 +0200 Subject: [PATCH 11/11] fix(cli): stop hardcoding opencode in kilo pr (#6824) * fix(cli): stop hardcoding opencode in kilo pr * fix(cli): stop hardcoding opencode in kilo pr * fix pr command cli resolution for subcommand args --- packages/opencode/src/cli/cmd/pr.ts | 37 +++++++++++++---- packages/opencode/test/cli/pr.test.ts | 60 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 packages/opencode/test/cli/pr.test.ts diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 936cff7cc1..c61d951027 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -4,9 +4,30 @@ import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { Instance } from "@/project/instance" import { Process } from "@/util" +import { existsSync } from "node:fs" // kilocode_change + +const subcommand = "pr" // kilocode_change + +// kilocode_change start - resolve the currently running CLI instead of hardcoding opencode +export function cliCommand( + input = { + execPath: process.execPath, + argv: process.argv, + exists: existsSync, + }, +) { + const script = input.argv[1] + if (!script) return [input.execPath] + if (script === subcommand) return [input.execPath] // kilocode_change + if (script.startsWith("/$bunfs/root/")) return [input.execPath] + if (script.startsWith("B:/~BUN/root/")) return [input.execPath] + if (input.exists(script)) return [input.execPath, script] + return [input.execPath] +} +// kilocode_change end export const PrCommand = cmd({ - command: "pr ", + command: `${subcommand} `, // kilocode_change describe: "fetch and checkout a GitHub PR branch, then run kilo", // kilocode_change builder: (yargs) => yargs.positional("number", { @@ -26,6 +47,7 @@ export const PrCommand = cmd({ const prNumber = args.number const localBranchName = `pr/${prNumber}` + const cli = cliCommand() // kilocode_change UI.println(`Fetching and checking out PR #${prNumber}...`) // Use gh pr checkout with custom branch name @@ -102,9 +124,7 @@ export const PrCommand = cmd({ UI.println(`Found session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = await Process.text(["kilo", "import", sessionUrl], { - nothrow: true, - }) + const importResult = await Process.text([...cli, "import", sessionUrl], { nothrow: true }) // kilocode_change end if (importResult.code === 0) { const importOutput = importResult.text.trim() @@ -122,13 +142,12 @@ export const PrCommand = cmd({ UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) UI.println() - const bin = "kilo" // kilocode_change - UI.println(`Starting ${bin}...`) // kilocode_change + UI.println("Starting kilo...") // kilocode_change UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const run = sessionId ? [...cli, "-s", sessionId] : cli // kilocode_change // kilocode_change start - const opencodeProcess = Process.spawn([bin, ...opencodeArgs], { + const opencodeProcess = Process.spawn(run, { // kilocode_change end stdin: "inherit", stdout: "inherit", @@ -136,7 +155,7 @@ export const PrCommand = cmd({ cwd: process.cwd(), }) const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`${bin} exited with code ${code}`) // kilocode_change + if (code !== 0) throw new Error(`kilo exited with code ${code}`) // kilocode_change }, }) }, diff --git a/packages/opencode/test/cli/pr.test.ts b/packages/opencode/test/cli/pr.test.ts new file mode 100644 index 0000000000..6101784d5c --- /dev/null +++ b/packages/opencode/test/cli/pr.test.ts @@ -0,0 +1,60 @@ +// kilocode_change - new file +import { expect, test } from "bun:test" +import { cliCommand } from "../../src/cli/cmd/pr" + +test("cliCommand uses the current script when argv[1] is a file path", () => { + const result = cliCommand({ + execPath: "/usr/bin/node", + argv: ["/usr/bin/node", "/tmp/kilo.js", "pr", "1"], + exists: (file) => file === "/tmp/kilo.js", + }) + + expect(result).toEqual(["/usr/bin/node", "/tmp/kilo.js"]) +}) + +test("cliCommand falls back to execPath when argv[1] is a subcommand", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo", "pr", "1"], + exists: () => false, + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand ignores subcommand token even when it exists on disk", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo", "pr", "1"], + exists: (file) => file === "pr", + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand falls back to execPath when argv[1] is missing", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo"], + exists: () => false, + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand falls back to execPath for bun virtual script paths", () => { + const unix = cliCommand({ + execPath: "/tmp/kilo", + argv: ["/tmp/kilo", "/$bunfs/root/src/index.js", "pr", "1"], + exists: () => true, + }) + + const win = cliCommand({ + execPath: "C:/tmp/kilo.exe", + argv: ["C:/tmp/kilo.exe", "B:/~BUN/root/src/index.js", "pr", "1"], + exists: () => true, + }) + + expect(unix).toEqual(["/tmp/kilo"]) + expect(win).toEqual(["C:/tmp/kilo.exe"]) +})