Files
Kilo-Org_kilocode/packages/opencode/test/kilocode/task-nesting.test.ts

320 lines
10 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import { Effect, Exit, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "../../src/background/job"
import { Bus } from "../../src/bus"
import { SessionRunState } from "../../src/session/run-state"
import { SessionStatus } from "../../src/session/status"
import { Config } from "../../src/config/config"
import { RuntimeFlags } from "../../src/effect/runtime-flags"
import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner"
import { Session } from "../../src/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Provider } from "../../src/provider/provider"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { Truncate } from "../../src/tool/truncate"
import { ToolRegistry } from "../../src/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
Bus.defaultLayer,
Config.defaultLayer,
RuntimeFlags.layer(),
SessionRunState.defaultLayer,
SessionStatus.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
Truncate.defaultLayer,
Provider.defaultLayer,
ToolRegistry.defaultLayer,
),
)
afterEach(async () => {
await disposeAllInstances()
})
const seed = Effect.fn("NestedTaskToolTest.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 }
})
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void }): TaskPromptOps {
const prompt = (input: SessionPrompt.PromptInput) =>
Effect.sync(() => {
opts?.onPrompt?.(input)
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: ref.modelID,
providerID: ref.providerID,
time: { created: Date.now() },
finish: "stop",
},
parts: [
{
id: PartID.ascending(),
messageID: id,
sessionID: input.sessionID,
type: "text",
text: "done",
},
],
} satisfies MessageV2.WithParts
})
return {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt,
loop: (input) => prompt({ sessionID: input.sessionID, parts: [] }),
}
}
describe("Kilo task nesting", () => {
it.live("allows primary agents to delegate one level to a subagent", () =>
provideTmpdirInstance(() =>
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: "explore",
},
{
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(kids[0]?.parentID).toBe(chat.id)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
expect(seen?.agent).toBe("explore")
}),
),
)
it.live("disables nested task and question tools even when global permissions allow them", () =>
provideTmpdirInstance(
() =>
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: "explore",
},
{
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(seen?.tools?.task).toBe(false)
expect(seen?.tools?.question).toBe(false)
expect(child.permission).toEqual(
expect.arrayContaining([
{
permission: "task",
pattern: "*",
action: "deny",
},
{
permission: "question",
pattern: "*",
action: "deny",
},
]),
)
}),
{
config: {
permission: {
task: "allow",
question: "allow",
},
},
},
),
)
test("preserves inherited restrictions while refreshing prompt tool toggles", () => {
const permission = KiloSessionPrompt.mergeToolPermissions({
existing: [
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "deny" },
],
toggles: [
{ permission: "task", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "allow" },
],
})
expect(permission).toEqual([
{ permission: "bash", pattern: "*", action: "deny" },
{ permission: "task", pattern: "*", action: "deny" },
{ permission: "edit", pattern: "*", action: "allow" },
])
})
it.live("refreshes inherited restrictions when resuming a task child", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
yield* sessions.setPermission({
sessionID: chat.id,
permission: [{ permission: "bash", pattern: "*", action: "deny" }],
})
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool
const def = yield* tool.init()
const exec = () =>
def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "explore",
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,
},
)
yield* exec()
const first = yield* sessions.get(child.id)
const count = first.permission?.filter((rule) => rule.permission === "bash").length
yield* exec()
const resumed = yield* sessions.get(child.id)
expect(resumed.permission).toEqual(
expect.arrayContaining([{ permission: "bash", pattern: "*", action: "deny" }]),
)
expect(count).toBeGreaterThan(0)
expect(resumed.permission?.filter((rule) => rule.permission === "bash")).toHaveLength(count ?? 0)
}),
),
)
it.live("rejects task_id from a different parent session", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const foreign = yield* sessions.create({ title: "Foreign parent" })
const child = yield* sessions.create({ parentID: foreign.id, title: "Foreign child" })
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: "explore",
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,
},
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
expect(yield* sessions.children(chat.id)).toHaveLength(0)
}),
),
)
})