mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-19 10:02:04 +08:00
+25







![opencode-agent[bot]](/assets/img/avatar_default.png)

Johnny Eric Amancio
opencode-agent[bot]
LukeParkerDev
Shoubhit Dash
Simon Klee
Brendan Allan
Brendan Allan
starptech
Cortex Code
Dax
Dax Raad
Frank
opencode-agent[bot]
Aiden Cline
Michael Hart
smagnuso
Orca丶
Adam
Aarav Sareen
Kit Langton
James Long
Dustin Deus
Ulises Jeremias
Jack
Jérôme Benoit
Ariane Emory
LIU Xinyu
Colin McDonnell
Sebastian
opencode
Kamesh Sampath
pcadena-lila
weiconghe
alberto
kilo-maintainer[bot]
ef6b152ff8
* feat(worktree): add managed workspace cloning (#30117) * test(tui): skip crashing keymap textarea renderer * fix(core): allow skipping migration execution * fix(opencode): remove automatic full session diffs (#30127) * chore: generate * refactor(worktree): move project out of repository * zen: deepseek flash * fix(tui): remount session view on session switch (#30129) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * go: minimax m3 * refactor(opencode): inline local provider helpers (#30169) * refactor(opencode): simplify provider setup flow (#30173) * fix(app): show project sessions before path sync resolves (#30167) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * fix(core): preserve session metadata migration identity (#30176) * refactor(session): align namespace imports and inline trivial helpers (#30180) * opencode(run): add queued prompt management (#30103) Direct run mode previously made submitted follow-up prompts irrevocable while a response was still running. Let users edit or remove queued prompts before dispatch without interrupting the active turn. * chore: generate * fix(acp): honor session/cancel by aborting the running turn (#30145) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(tui): prevent prompt corruption when pasting near wide characters (#29710) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> * fix(opencode): avoid nullable webfetch format schema (#30215) * chore: generate * fix(core): contain lsp warmup defects (#30226) * add run --replay mode (#30239) * chore: generate * chore: update nix node_modules hashes * fix(stats): restore leaderboard spacing * fix(stats): center top models dot grid * fix(stats): stabilize top models hover * fix(stats): align big-pickle provider resolution (#30274) * feat(app): v2 desktop UI improvements (#29689) Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> * chore: generate * fix(tui): clarify inline subagent rows (#30051) * fix(tui): handle events across workspaces (#30281) * feat(core): update Copilot for token-based billing (#30181) * fix(tui): keep background marker with subagent label (#30271) * fix(tui): keep retry attempt before message (#30275) * chore: generate * fix(opencode): enforce storage path invariants (#29666) * chore: generate * feat(core): add location-based permission service (#30287) * chore: generate * fix(tui): preserve live parts during session hydration (#30300) * fix(app): restore deferred MCP status updates (#30220) * fix: export v2 stylesheets and declare core node types (#30312) * chore: update nix node_modules hashes * fix(app): avoid suspending on pending child path (#30314) * fix(opencode): remove sunsetted gpt-5.2 and gpt-5.3-codex from allowed models for codex subscriptions (#30316) * chore: generate * feat(core): expose session location * chore: generate * fix(opencode): preserve websocket api errors (#30321) * refactor(core): simplify session pagination * feat(core): add location filesystem contract * feat(core): add dummy location filesystem layer * chore: generate * feat(opencode): add filesystem read and list routes * chore: generate * infra: stats * sync * feat(app): inset new layout session panels (#30342) * fix(app): tab title truncation and close button positioning (#30349) * tui: show model context in run footer (#30380) * tui: revert OpenTUI upgrade to 0.2.16 (#30383) * chore: update nix node_modules hashes * feat(core): add managed repository cache (#30408) * chore: generate * chore: generate * sync * feat(stats): add cache ratio section * feat(core): add flagged project references (#30414) * chore: generate * feat(core): support named migrations (#30418) * fix(stats): clean retired provider rows during sync (#30420) * fix(stats): mention opencode go in top models copy * feat(core): expose project reference filesystem access (#30423) * chore: generate * sync * fix(tui): scope diff viewer to session directory (#30426) * test: widen provider header timeout margin (#30427) * fix(plugin): restore private git install fallback (#30430) * fix(stats): remove leaderboard nav link * chore(opencode): remove scout agent (#30435) * chore: generate * feat(stats): improve cache ratio chart * chore: generate * fix(effect-drizzle-sqlite): preserve transaction begin errors (#30448) * chore: bump effect beta to 74 (#30449) * Revert "tui: revert OpenTUI upgrade to 0.2.16 (#30383)" (#30452) * chore: update nix node_modules hashes * refactor(opencode): improve startup time by 38% (#30453) Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> * chore: generate * fix(opencode): patch empty Gemini replay messages (#30463) * chore: generate * refactor(core): consolidate filesystem services (#30447) * chore: generate * run: enable interactive replay by default (#30465) * chore: update nix node_modules hashes * refactor(opencode): remove JSON storage migration (#30461) * chore: generate * chore: update nix node_modules hashes * fix(tui): stop idle background task spinner (#30484) * refactor(core): move v1 schemas into core (#30473) * chore: generate * fix: task id passed to background job for continuation (#30485) * chore: generate * feat(core): project copying and tracking directories (#30139) * chore: generate * fix(opencode): preserve signed thinking during anthropic reorder (#30182) * Revert "fix(opencode): preserve signed thinking during anthropic reorder" (#30502) * fix: rm tool reorder logic from old bug (#30483) * chore: generate * feat(app): polish home projects list UI (#30436) * feat(app): polish select-v2 component (#30446) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(github): enforce existing git author identity (#30507) * feat(app): new update button (#30460) Co-authored-by: Brendan Allan <git@brendonovich.dev> * fix(opencode): fallback to sh for curl upgrade (#30499) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(ui): render whole-file patches as complete diffs (#30516) * chore: generate * feat(app): add servers tab to settings dialog (#29675) * refactor(core): consolidate pty service (#30537) * chore: generate * tui: truncate sidebar file paths (#30531) * chore: update nix node_modules hashes * feat(stats): add geo breakdown (#30456) * chore: generate * chore: update nix node_modules hashes * fix(acp): classify apply_patch as edit (#30564) * fix(acp): classify task as think (#30565) * fix(acp): include external directory permission context (#30567) * fix(acp): clean read tool display content (#30569) * fix(tui): route question responses by session directory (#30578) * fix(stats): serve stats og image from banner * docs(go): add Qwen3.7 Plus model (#30594) * fix(openai): preserve websocket idle state (#30586) * refactor(core): remove ai sdk option fields (#30581) * chore: generate * test(core): cover v1 provider option lowering (#30599) * chore: generate * refactor(core): nest model api id (#30603) * fix(core): expose azure openai xhigh efforts (#30620) * feat(core): add skill registry and file agent loading (#30617) * chore: generate * chore: update nix node_modules hashes * fix(stats): count all go usage * chore: remove zed extension and automation (#30628) * fix(opencode): preserve variant for delegated tasks (#30630) * zen: update nvidia tos * fix(opencode): route SAP AI Core reasoning variants through modelParams (#30482) * chore: generate * fix(app): hide unavailable titlebar update (#30642) * feat(app): v2 thinking level selector (#30646) * fix(app,ui): session review reactivity and VCS query cache (#30660) * feat(core): add embedded v2 session runtime and tool foundation (#30632) * chore: generate * chore: update nix node_modules hashes * docs: correct compaction prune default (#30670) * fix(opencode): avoid shell cancel race (#30641) * feat: bump bedrock and add proper mantle support for openai models through aws bedrock (#30464) * test: wait for shell truncation readiness (#30679) * chore: update nix node_modules hashes * refactor(opencode): clean up task tool prompts (#30687) * feat(core): add command registry (#30624) * chore: generate * fix(acp): replay loaded session transcript (#30645) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): reset pre-launch session projections (#30728) * feat(tui): improve experimental session switcher (#30738) * fix(opencode): respect disabled auto compaction on overflow (#30749) * zen: nemotron 3 ultra * fix(enterprise): install hono standard validator peer (#30740) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix build * chore: update nix node_modules hashes * make scripts executable * fix(tui): show toast when variant_list keybind used with no variants (#30724) * fix(opencode): `ACP.loadSession` should replay all messages (#30761) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(opencode): attribute task child agent on creation (#30786) * fix(tui): add Vue syntax highlighting (#30802) * fix: bump @openrouter/ai-sdk-provider to 2.9.0 (#30800) * feat(core): moving sessions (#30640) * chore: generate * tweak: background agent prompting to avoid polling issues (#30790) * upgrade opentui to 0.3.2 (#30748) * chore: update nix node_modules hashes * feat(desktop): surface local server startup failures (#30822) * ci: publish * refactor(core): make v2 session inputs event sourced (#30785) * chore: generate * fix(llm): normalize OpenAI function tool schemas * chore: generate * feat(stats): refresh stats routes and homepage (#30419) * fix(stats): sort metric charts by top usage * feat(core): add public native API (#30828) * chore: generate * feat(app): color themes (#30824) Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> * chore: generate * sync release versions for v1.16.0 * feat(core): attach global native tools (#30832) * chore: generate * feat(core): add Snowflake Cortex provider (#29901) Co-authored-by: Cortex Code <noreply@snowflake.com> * chore: generate * feat(core): persist v2 session context epochs (#30789) * chore: generate * feat(tui): allow backgrounding synchronous subagents (#30488) * fix(app): improve tab handling (#30669) * chore: generate * fix(tui): prioritize models slash autocomplete (#30848) * fix(tui): route permission replies to session directory (#30851) Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> * fix(cli): harden daemon lifecycle (#30844) * chore: generate * feat(app): improve desktop multi-server support (#30678) Co-authored-by: Brendan Allan <git@brendonovich.dev> * chore: generate * fix(app): handle tab overflow and scrolling in titlebar (#30886) * fix(app): tab overflow (#30894) * tui: guard path formatting inputs (#30469) Fixes #27726, #25216, #24856, #24294, #17071, #29164, #24837, #16865, #14279, #29895 * opencode/run: refresh themes after terminal reloads (#30917) * chore: generate * fix(tui): fall back to local cwd when editor spawns in attach mode (#30583) * docs: update Go Qwen tiered pricing (#30936) * chore: generate * feat(tui): add diff hunk navigation (#30935) * chore: rm fuzzy search on references (#30931) * fix: use mapError instead of orDie for context snapshot decoding (#30905) Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> * fix(core): recover corrupted models cache (#30947) * chore: bun install (#30968) * fix(opencode): resolve Bedrock hang by using node build conditions (#30873) * fix(workflows): retry nix-hashes compute-hash on transient failure (#30743) * fix(stats): scroll model charts to latest on mobile * fix(opencode): prevent destructive edit matches (#30932) * chore: generate * fix(core): respect v2 default agents (#30969) * chore: generate * test(opencode): remove disposal event wait race (#30971) * test(opencode): remove shell timeout output race (#30974) * fix(opencode): gate reasoning summaries by provider (#30973) * feat(core): admit v2 skill guidance (#30843) * fix(workflows): serialize desktop release uploads (#30978) * fix(stats): add mobile chart end spacing * release: v1.16.2 * refactor: kilo compat for v1.16.2 * fix(opencode): address v1.16.2 merge regressions * chore: update kilo-vscode visual regression baselines * fix(opencode): restore Kilo behavior after v1.16.2 merge * fix(opencode): retry Windows migration cleanup * test(opencode): restore clone and macOS watcher coverage * fix(opencode): address second-pass review for #12099 Preserve imported usage and retry partial JSON migrations. Refresh active dependency patches, remove the obsolete GCP patch, and regenerate Kilo HttpApi branding. --------- Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Michael Hart <mhart@cloudflare.com> Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: smagnuso <smagnuso@gmail.com> Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com> Co-authored-by: Orca丶 <93272799+dauphinYan@users.noreply.github.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: starptech <starptech@starptechs-MBP.fritz.box> Co-authored-by: Ulises Jeremias <ulisescf.24@gmail.com> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Co-authored-by: LIU Xinyu <contact@lxy.cc> Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Kamesh Sampath <kamesh.sampath@hotmail.com> Co-authored-by: Cortex Code <noreply@snowflake.com> Co-authored-by: pcadena-lila <pcadena@lila.ai> Co-authored-by: weiconghe <46336277+weiconghe@users.noreply.github.com> Co-authored-by: alberto <914199+alblez@users.noreply.github.com> Co-authored-by: kilo-maintainer[bot] <kilo-maintainer[bot]@users.noreply.github.com>
1396 lines
47 KiB
TypeScript
1396 lines
47 KiB
TypeScript
import { afterEach, describe, expect } from "bun:test"
|
|
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect" // kilocode_change - Cause/Deferred for resume-hint coverage
|
|
import { Agent } from "../../src/agent/agent"
|
|
import { BackgroundJob } from "@/background/job"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { Config } from "@/config/config"
|
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|
import { Session } from "@/session/session"
|
|
import { MessageV2 } from "@/session/message-v2" // kilocode_change
|
|
import type { SessionPrompt } from "../../src/session/prompt"
|
|
import { MessageID, PartID, SessionID } from "../../src/session/schema" // kilocode_change - SessionID used by cost propagation tests
|
|
import { SessionRunState } from "@/session/run-state"
|
|
import { SessionStatus } from "@/session/status"
|
|
import { Provider } from "../../src/provider/provider" // kilocode_change
|
|
import { KiloSession } from "../../src/kilocode/session" // kilocode_change
|
|
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
|
import { Truncate } from "@/tool/truncate"
|
|
import { ToolRegistry } from "@/tool/registry"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
|
import { testEffect } from "../lib/effect"
|
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
|
import { ModelV2 } from "@opencode-ai/core/model"
|
|
|
|
afterEach(async () => {
|
|
await disposeAllInstances()
|
|
})
|
|
|
|
const ref = {
|
|
providerID: ProviderV2.ID.make("test"),
|
|
modelID: ModelV2.ID.make("test-model"),
|
|
}
|
|
|
|
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|
Layer.mergeAll(
|
|
Agent.defaultLayer,
|
|
BackgroundJob.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
Config.defaultLayer,
|
|
CrossSpawnSpawner.defaultLayer,
|
|
Session.defaultLayer,
|
|
SessionRunState.defaultLayer,
|
|
SessionStatus.defaultLayer,
|
|
Truncate.defaultLayer,
|
|
Provider.defaultLayer, // kilocode_change
|
|
ToolRegistry.defaultLayer,
|
|
Database.defaultLayer,
|
|
RuntimeFlags.layer(flags),
|
|
)
|
|
|
|
const it = testEffect(layer())
|
|
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
|
|
|
function defer<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void
|
|
const promise = new Promise<T>((done) => {
|
|
resolve = done
|
|
})
|
|
return { promise, resolve }
|
|
}
|
|
|
|
const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
|
|
const session = yield* Session.Service
|
|
const chat = yield* session.create({ title })
|
|
const user = yield* session.updateMessage({
|
|
id: MessageID.ascending(),
|
|
role: "user",
|
|
sessionID: chat.id,
|
|
agent: "build",
|
|
model: ref,
|
|
time: { created: Date.now() },
|
|
})
|
|
const assistant: SessionV1.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,
|
|
variant: "xhigh",
|
|
time: { created: Date.now() },
|
|
}
|
|
yield* session.updateMessage(assistant)
|
|
return { chat, assistant }
|
|
})
|
|
|
|
// 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: () => Effect.void,
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) =>
|
|
Effect.gen(function* () {
|
|
opts?.onPrompt?.(input)
|
|
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): SessionV1.WithParts {
|
|
const id = MessageID.ascending()
|
|
return {
|
|
info: {
|
|
id,
|
|
role: "assistant",
|
|
parentID: input.messageID ?? MessageID.ascending(),
|
|
sessionID: input.sessionID,
|
|
mode: input.agent ?? "general",
|
|
agent: input.agent ?? "general",
|
|
cost: 0,
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
modelID: input.model?.modelID ?? ref.modelID,
|
|
providerID: input.model?.providerID ?? ref.providerID,
|
|
time: { created: Date.now() },
|
|
finish: "stop",
|
|
},
|
|
parts: [
|
|
{
|
|
id: PartID.ascending(),
|
|
messageID: id,
|
|
sessionID: input.sessionID,
|
|
type: "text",
|
|
text,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
describe("tool.task", () => {
|
|
it.instance(
|
|
"description sorts subagents by name and is stable across calls",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
const build = yield* agent.get("build")
|
|
const registry = yield* ToolRegistry.Service
|
|
const get = Effect.fnUntraced(function* () {
|
|
const tools = yield* registry.tools({ ...ref, agent: build })
|
|
return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
|
|
})
|
|
const first = yield* get()
|
|
const second = yield* get()
|
|
|
|
expect(first).toBe(second)
|
|
|
|
const alpha = first.indexOf("- alpha: Alpha agent")
|
|
const explore = first.indexOf("- explore:")
|
|
const general = first.indexOf("- general:")
|
|
const zebra = first.indexOf("- zebra: Zebra agent")
|
|
|
|
expect(alpha).toBeGreaterThan(-1)
|
|
expect(explore).toBeGreaterThan(alpha)
|
|
expect(general).toBeGreaterThan(explore)
|
|
expect(zebra).toBeGreaterThan(general)
|
|
}),
|
|
{
|
|
config: {
|
|
agent: {
|
|
zebra: {
|
|
description: "Zebra agent",
|
|
mode: "subagent",
|
|
},
|
|
alpha: {
|
|
description: "Alpha agent",
|
|
mode: "subagent",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance(
|
|
"description hides denied subagents for the caller",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const agent = yield* Agent.Service
|
|
const build = yield* agent.get("build")
|
|
const registry = yield* ToolRegistry.Service
|
|
const description =
|
|
(yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
|
|
|
|
expect(description).toContain("- alpha: Alpha agent")
|
|
expect(description).not.toContain("- zebra: Zebra agent")
|
|
}),
|
|
{
|
|
config: {
|
|
permission: {
|
|
task: {
|
|
"*": "allow",
|
|
zebra: "deny",
|
|
},
|
|
},
|
|
agent: {
|
|
zebra: {
|
|
description: "Zebra agent",
|
|
mode: "subagent",
|
|
},
|
|
alpha: {
|
|
description: "Alpha agent",
|
|
mode: "subagent",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
it.instance("execute resumes an existing task session from task_id", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
|
|
|
const result = 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 kids = yield* sessions.children(chat.id)
|
|
expect(kids).toHaveLength(1)
|
|
expect(kids[0]?.id).toBe(child.id)
|
|
expect(result.metadata.sessionId).toBe(child.id)
|
|
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
|
|
expect(seen?.sessionID).toBe(child.id)
|
|
expect(seen?.variant).toBe("xhigh")
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start - resumed children rebuild parent platform attribution after restart
|
|
it.instance("execute preserves platform attribution when resuming a task", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
KiloSession.setPlatformOverride(chat.id, "agent-manager")
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
|
KiloSession.clearPlatformOverride(child.id)
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "continue",
|
|
subagent_type: "general",
|
|
task_id: child.id,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps() },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
expect(KiloSession.resolvePlatform(child.id)).toBe("agent-manager")
|
|
expect(KiloSession.resolveRoot(child.id)).toBe(chat.id)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
it.instance("execute asks by default and skips checks when bypassed", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const calls: unknown[] = []
|
|
const promptOps = stubOps()
|
|
|
|
const exec = (extra?: Record<string, any>) =>
|
|
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, ...extra },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: (input) =>
|
|
Effect.sync(() => {
|
|
calls.push(input)
|
|
}),
|
|
},
|
|
)
|
|
|
|
yield* exec()
|
|
yield* exec({ bypassAgentCheck: true })
|
|
|
|
expect(calls).toHaveLength(1)
|
|
expect(calls[0]).toEqual({
|
|
permission: "task",
|
|
patterns: ["general"],
|
|
always: ["*"],
|
|
metadata: {
|
|
description: "inspect bug",
|
|
subagent_type: "general",
|
|
},
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.instance("execute cancels child session when abort signal fires", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const ready = defer<SessionPrompt.PromptInput>()
|
|
const cancelled = defer<SessionID>()
|
|
const abort = new AbortController()
|
|
const promptOps: TaskPromptOps = {
|
|
cancel: (sessionID) =>
|
|
Effect.sync(() => {
|
|
cancelled.resolve(sessionID)
|
|
}),
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) =>
|
|
Effect.promise(() => {
|
|
ready.resolve(input)
|
|
return cancelled.promise
|
|
}).pipe(Effect.as(reply(input, "cancelled"))),
|
|
}
|
|
|
|
const fiber = 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: abort.signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
.pipe(Effect.forkChild)
|
|
|
|
const input = yield* Effect.promise(() => ready.promise)
|
|
abort.abort()
|
|
expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
|
|
|
|
const exit = yield* Fiber.await(fiber)
|
|
expect(Exit.isSuccess(exit)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance("execute creates a child when task_id does not exist", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
task_id: "ses_missing",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const kids = yield* sessions.children(chat.id)
|
|
expect(kids).toHaveLength(1)
|
|
expect(kids[0]?.id).toBe(result.metadata.sessionId)
|
|
expect(result.metadata.sessionId).not.toBe("ses_missing")
|
|
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
|
|
expect(seen?.sessionID).toBe(result.metadata.sessionId)
|
|
}),
|
|
)
|
|
|
|
it.instance(
|
|
"execute shapes child permissions for task, todowrite, and primary tools",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
let seen: SessionPrompt.PromptInput | undefined
|
|
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "reviewer",
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const child = yield* sessions.get(result.metadata.sessionId)
|
|
expect(child.parentID).toBe(chat.id)
|
|
expect(child.agent).toBe("reviewer")
|
|
// kilocode_change start — use arrayContaining: Kilo appends inherited caller restrictions
|
|
expect(child.permission).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
permission: "todowrite",
|
|
pattern: "*",
|
|
action: "deny",
|
|
},
|
|
{
|
|
permission: "bash",
|
|
pattern: "*",
|
|
action: "allow",
|
|
},
|
|
{
|
|
permission: "read",
|
|
pattern: "*",
|
|
action: "allow",
|
|
},
|
|
{
|
|
permission: "task",
|
|
pattern: "*",
|
|
action: "deny",
|
|
},
|
|
]),
|
|
)
|
|
// kilocode_change end
|
|
expect(seen?.tools).toEqual({
|
|
question: false, // kilocode_change - subagents cannot prompt the user directly
|
|
interactive_terminal: false, // kilocode_change - subagents cannot take over the user's terminal
|
|
todowrite: false,
|
|
task: false, // kilocode_change - Kilo disallows nested subagents
|
|
bash: false,
|
|
read: false,
|
|
})
|
|
}),
|
|
{
|
|
config: {
|
|
agent: {
|
|
reviewer: {
|
|
mode: "subagent",
|
|
permission: {
|
|
task: "allow",
|
|
},
|
|
},
|
|
},
|
|
experimental: {
|
|
primary_tools: ["bash", "read"],
|
|
openTelemetry: true, // kilocode_change
|
|
},
|
|
},
|
|
},
|
|
)
|
|
|
|
// kilocode_change start - terminal child assistant errors fail the task tool boundary
|
|
it.instance("execute fails when child prompt returns assistant error", () =>
|
|
Effect.gen(function* () {
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const promptOps: TaskPromptOps = {
|
|
cancel: () => Effect.void,
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) =>
|
|
Effect.sync(() => {
|
|
const result = reply(input, "partial")
|
|
if (result.info.role !== "assistant") return result
|
|
return {
|
|
...result,
|
|
info: {
|
|
...result.info,
|
|
error: MessageV2.fromError(new Error("child prompt failed"), { providerID: ref.providerID }),
|
|
},
|
|
}
|
|
}),
|
|
}
|
|
|
|
const exit = 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,
|
|
},
|
|
)
|
|
.pipe(Effect.exit)
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
|
|
// the failure surfaces the resumable task_id so the parent can continue the subagent (#11620)
|
|
const kids = yield* sessions.children(chat.id)
|
|
const childId = kids[0]?.id
|
|
expect(childId).toBeDefined()
|
|
const squashed = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined
|
|
const message = squashed instanceof Error ? squashed.message : String(squashed)
|
|
expect(message).toContain("child prompt failed")
|
|
expect(message).toContain(`task_id="${childId}"`)
|
|
expect(message).toContain("can be resumed")
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start - background subagent failures also surface the resumable task_id (#11620)
|
|
background.instance("background task failure injects a resumable task_id into the parent", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const injected: SessionPrompt.PromptInput[] = []
|
|
const parentInjected = yield* Deferred.make<void>()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: (input) => {
|
|
// The parent-session prompt is the injected background result; capture it.
|
|
if (input.sessionID === chat.id) {
|
|
injected.push(input)
|
|
return Effect.as(Deferred.succeed(parentInjected, undefined), reply(input, "ack"))
|
|
}
|
|
return Effect.die(new Error("child prompt failed and can be resumed later"))
|
|
},
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const childId = result.metadata.sessionId
|
|
yield* jobs.wait({ id: childId, timeout: 1_000 })
|
|
// The parent-session injection is forked asynchronously; wait for it before asserting.
|
|
yield* Deferred.await(parentInjected).pipe(Effect.timeout("1 second"))
|
|
|
|
const text = injected
|
|
.flatMap((input) => input.parts ?? [])
|
|
.map((part) => (part.type === "text" ? part.text : ""))
|
|
.join("\n")
|
|
expect(text).toContain(`state="error"`)
|
|
expect(text).toContain(`task_id="${childId}"`)
|
|
expect(text).toContain("can be resumed")
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
it.instance("rejects background execution when the experiment is disabled", () =>
|
|
Effect.gen(function* () {
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const exit = yield* def
|
|
.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps() },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
.pipe(Effect.exit)
|
|
|
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
}),
|
|
)
|
|
|
|
it.instance("promotes a running foreground task without restarting it", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const ready = yield* Deferred.make<void>()
|
|
const done = yield* Deferred.make<void>()
|
|
const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
|
|
let runs = 0
|
|
const promptOps: TaskPromptOps = {
|
|
cancel: () => Effect.void,
|
|
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
|
|
prompt: (input) => {
|
|
if (input.sessionID === chat.id) {
|
|
return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
|
|
}
|
|
return Effect.gen(function* () {
|
|
runs += 1
|
|
yield* Deferred.succeed(ready, undefined)
|
|
yield* Deferred.await(done)
|
|
return reply(input, "background done")
|
|
})
|
|
},
|
|
}
|
|
|
|
const fiber = 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,
|
|
},
|
|
)
|
|
.pipe(Effect.forkChild)
|
|
|
|
yield* Deferred.await(ready)
|
|
const job = (yield* jobs.list())[0]
|
|
expect(job).toBeDefined()
|
|
if (!job) throw new Error("task job not found")
|
|
expect(job.metadata?.parentSessionId).toBe(chat.id)
|
|
yield* jobs.promote(job.id)
|
|
|
|
const result = yield* Fiber.join(fiber)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain(`state="running"`)
|
|
expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
|
|
expect(runs).toBe(1)
|
|
|
|
yield* Deferred.succeed(done, undefined)
|
|
expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
|
|
expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
|
|
expect(runs).toBe(1)
|
|
}),
|
|
)
|
|
|
|
background.instance("execute launches background tasks without waiting for completion", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const job = yield* jobs.get(result.metadata.sessionId)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain(`state="running"`)
|
|
expect(job?.status).toBe("running")
|
|
}),
|
|
)
|
|
|
|
background.instance("background task completion waits for running updates", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const first = defer<void>()
|
|
const second = defer<void>()
|
|
const updated = defer<SessionPrompt.PromptInput>()
|
|
const injected = defer<SessionPrompt.PromptInput>()
|
|
let prompts = 0
|
|
const promptOps: TaskPromptOps = {
|
|
...stubOps(),
|
|
prompt: (input) => {
|
|
if (input.sessionID === chat.id) {
|
|
injected.resolve(input)
|
|
return Effect.succeed(reply(input, "done"))
|
|
}
|
|
prompts++
|
|
if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
|
|
updated.resolve(input)
|
|
return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done")))
|
|
},
|
|
}
|
|
const context = {
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
}
|
|
|
|
const started = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
context,
|
|
)
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "add investigation scope",
|
|
prompt: "also inspect cancellation",
|
|
subagent_type: "general",
|
|
task_id: started.metadata.sessionId,
|
|
},
|
|
context,
|
|
)
|
|
|
|
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
|
|
expect(result.metadata.background).toBe(true)
|
|
expect(result.output).toContain("Background task updated")
|
|
first.resolve()
|
|
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
|
|
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
|
|
{ type: "text", text: "also inspect cancellation" },
|
|
])
|
|
|
|
second.resolve()
|
|
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.info?.status).toBe("completed")
|
|
expect(waited.info?.output).toBe("second done")
|
|
const notification = yield* Effect.promise(() => injected.promise)
|
|
expect(notification.variant).toBe("xhigh")
|
|
expect(notification.parts[0]?.type).toBe("text")
|
|
if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done")
|
|
}),
|
|
)
|
|
|
|
// kilocode_change start - completed background tasks propagate their invocation cost delta
|
|
background.instance("background tasks propagate child cost to the parent", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps({ sessions, childCost: 0.2 }) },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
const parent = (yield* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === assistant.id)!
|
|
expect(parent.info.role === "assistant" ? parent.info.cost : 0).toBeCloseTo(0.2, 6)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
// kilocode_change start - the background.extend() path must also propagate its run's cost delta (regression)
|
|
background.instance("extended background tasks propagate the extended run's cost to the parent", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
const first = defer<void>()
|
|
const second = defer<void>()
|
|
let childPrompts = 0
|
|
// Each child prompt persists a 0.2 cost delta, so the child session totals 0.2 after the
|
|
// initial run and 0.4 after the extended run. Blocking each run keeps the job "running"
|
|
// long enough for the second execute() to hit background.extend() rather than a fresh start.
|
|
const promptOps: TaskPromptOps = {
|
|
...stubOps(),
|
|
prompt: (input) =>
|
|
Effect.gen(function* () {
|
|
const rep = reply(input, "done")
|
|
if (input.sessionID === chat.id) return rep
|
|
yield* sessions.updateMessage({ ...rep.info, cost: 0.2 })
|
|
childPrompts++
|
|
if (childPrompts === 1) yield* Effect.promise(() => first.promise)
|
|
else yield* Effect.promise(() => second.promise)
|
|
return rep
|
|
}),
|
|
}
|
|
const context = {
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
}
|
|
|
|
const started = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
context,
|
|
)
|
|
const extended = yield* def.execute(
|
|
{
|
|
description: "extend investigation",
|
|
prompt: "also inspect cancellation",
|
|
subagent_type: "general",
|
|
task_id: started.metadata.sessionId,
|
|
},
|
|
context,
|
|
)
|
|
expect(extended.metadata.sessionId).toBe(started.metadata.sessionId)
|
|
expect(extended.output).toContain("Background task updated")
|
|
|
|
first.resolve()
|
|
second.resolve()
|
|
yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
|
|
const parent = (yield* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === assistant.id)!
|
|
// Both the initial run and the extended run propagate their 0.2 delta; a missing bracket on the
|
|
// extend path would leave the parent at 0.2.
|
|
expect(parent.info.role === "assistant" ? parent.info.cost : 0).toBeCloseTo(0.4, 6)
|
|
}),
|
|
)
|
|
// kilocode_change end
|
|
|
|
background.instance("background tasks complete through the background job service", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: { promptOps: stubOps({ text: "background done" }) },
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("completed")
|
|
expect(waited.info?.output).toBe("background done")
|
|
}),
|
|
)
|
|
|
|
background.instance("background task completion does not wait for the parent async prompt", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps({ text: "background done" }),
|
|
prompt: (input) =>
|
|
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("completed")
|
|
}),
|
|
)
|
|
|
|
background.instance("removing the parent session cancels running background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* sessions.remove(chat.id)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
background.instance("removing the child task session cancels its running background task", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* sessions.remove(result.metadata.sessionId)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
background.instance("cancelling the parent run cancels running background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const { chat, assistant } = yield* seed()
|
|
const tool = yield* TaskTool
|
|
const def = yield* tool.init()
|
|
|
|
const result = yield* def.execute(
|
|
{
|
|
description: "inspect bug",
|
|
prompt: "look into the cache key path",
|
|
subagent_type: "general",
|
|
background: true,
|
|
},
|
|
{
|
|
sessionID: chat.id,
|
|
messageID: assistant.id,
|
|
agent: "build",
|
|
abort: new AbortController().signal,
|
|
extra: {
|
|
promptOps: {
|
|
...stubOps(),
|
|
prompt: () => Effect.never,
|
|
} satisfies TaskPromptOps,
|
|
},
|
|
messages: [],
|
|
metadata: () => Effect.void,
|
|
ask: () => Effect.void,
|
|
},
|
|
)
|
|
|
|
yield* runState.cancel(chat.id)
|
|
const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
|
|
expect(waited.timedOut).toBe(false)
|
|
expect(waited.info?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
|
|
|
|
yield* jobs.start({
|
|
id: child.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: chat.id, sessionId: child.id },
|
|
run: Effect.never,
|
|
})
|
|
|
|
yield* runState.cancel(child.id)
|
|
|
|
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
|
|
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
|
|
Effect.gen(function* () {
|
|
const jobs = yield* BackgroundJob.Service
|
|
const runState = yield* SessionRunState.Service
|
|
const sessions = yield* Session.Service
|
|
const { chat } = yield* seed()
|
|
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
|
|
const grandchild = yield* sessions.create({ parentID: child.id, title: "grandchild" })
|
|
|
|
yield* jobs.start({
|
|
id: child.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: chat.id, sessionId: child.id },
|
|
run: Effect.never,
|
|
})
|
|
yield* jobs.start({
|
|
id: grandchild.id,
|
|
type: "task",
|
|
metadata: { parentSessionId: child.id, sessionId: grandchild.id },
|
|
run: Effect.never,
|
|
})
|
|
|
|
yield* runState.cancel(chat.id)
|
|
|
|
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
|
|
expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled")
|
|
}),
|
|
)
|
|
})
|
|
|
|
// 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* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === 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* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === 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* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === 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: () => Effect.void,
|
|
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* sessions.messages({ sessionID: chat.id })).find((item) => item.info.id === assistant.id)!
|
|
if (parent.info.role !== "assistant") return
|
|
expect(parent.info.cost).toBeCloseTo(0.07, 6)
|
|
}),
|
|
),
|
|
)
|
|
})
|
|
// kilocode_change end
|