refactor(effect): build task tool from agent services

This commit is contained in:
Kit Langton
2026-04-08 13:37:49 -04:00
parent 988c9894f2
commit 186063fbed
5 changed files with 607 additions and 195 deletions

View File

@@ -47,7 +47,6 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -559,7 +558,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* registry.fromID(TaskTool.id)
const taskTool = yield* registry.fromID("task")
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -582,7 +581,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: TaskTool.id,
tool: "task",
state: {
status: "running",
input: {

View File

@@ -50,6 +50,10 @@ export namespace ToolRegistry {
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
@@ -67,6 +71,7 @@ export namespace ToolRegistry {
| Plugin.Service
| Question.Service
| Todo.Service
| Agent.Service
| LSP.Service
| FileTime.Service
| Instruction.Service
@@ -77,8 +82,18 @@ export namespace ToolRegistry {
const config = yield* Config.Service
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const info = <T extends Tool.Info, R = never>(
tool: T | Effect.Effect<T, never, R>,
): Effect.Effect<T, never, R> => (Effect.isEffect(tool) ? tool : Effect.succeed(tool))
const build = <T extends Tool.Info, R = never>(
tool: T | Effect.Effect<T, never, R>,
): Effect.Effect<Tool.Def, never, R> => info(tool).pipe(Effect.flatMap(Tool.init))
const task = yield* info(TaskTool)
const read = yield* info(ReadTool)
const askInfo = yield* info(QuestionTool)
const todoInfo = yield* info(TodoWriteTool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
@@ -135,31 +150,45 @@ export namespace ToolRegistry {
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
const invalid = yield* build(InvalidTool)
const bash = yield* build(BashTool)
const readDef = yield* build(read)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const taskDef = yield* build(task)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(todoInfo)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const ask = yield* build(askInfo)
const lsp = yield* build(LspTool)
const plan = yield* build(PlanExitTool)
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
builtin: [
invalid,
...(question ? [ask] : []),
bash,
readDef,
glob,
grep,
edit,
write,
taskDef,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
],
}
}),
)
@@ -208,8 +237,7 @@ export namespace ToolRegistry {
id: tool.id,
description: [
output.description,
// TODO: remove this hack
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === "task" ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
.filter(Boolean)
@@ -223,7 +251,7 @@ export namespace ToolRegistry {
)
})
return Service.of({ ids, tools, all, fromID })
return Service.of({ ids, all, named: { task, read }, tools, fromID })
}),
)
@@ -234,6 +262,7 @@ export namespace ToolRegistry {
Layer.provide(Plugin.defaultLayer),
Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer),
Layer.provide(Agent.defaultLayer),
Layer.provide(LSP.defaultLayer),
Layer.provide(FileTime.defaultLayer),
Layer.provide(Instruction.defaultLayer),

View File

@@ -6,96 +6,99 @@ import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
return {
description,
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
export const TaskTool = Tool.defineEffect(
"task",
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
const run = Effect.fn("TaskTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
const cfg = yield* config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
if (!ctx.extra?.bypassAgentCheck) {
await ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
})
yield* Effect.promise(() =>
ctx.ask({
permission: "task",
patterns: [params.subagent_type],
always: ["*"],
metadata: {
description: params.description,
subagent_type: params.subagent_type,
},
}),
)
}
const agent = await Agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`)
const next = yield* agent.get(params.subagent_type)
if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task")
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite")
const canTask = next.permission.some((rule) => rule.permission === "task")
const canTodo = next.permission.some((rule) => rule.permission === "todowrite")
const session = await iife(async () => {
if (params.task_id) {
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {})
if (found) return found
}
const taskID = params.task_id
const session = taskID
? yield* Effect.promise(() => {
const id = SessionID.make(taskID)
return Session.get(id).catch(() => undefined)
})
: undefined
const nextSession =
session ??
(yield* Effect.promise(() =>
Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${next.name} subagent)`,
permission: [
...(canTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(canTask
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
return await Session.create({
parentID: ctx.sessionID,
title: params.description + ` (@${agent.name} subagent)`,
permission: [
...(hasTodoWritePermission
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(hasTaskPermission
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(config.experimental?.primary_tools?.map((t) => ({
pattern: "*",
action: "allow" as const,
permission: t,
})) ?? []),
],
})
})
const msg = await MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID })
if (msg.info.role !== "assistant") throw new Error("Not an assistant message")
const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
const model = agent.model ?? {
const model = next.model ?? {
modelID: msg.info.modelID,
providerID: msg.info.providerID,
}
@@ -103,7 +106,7 @@ export const TaskTool = Tool.define("task", async () => {
ctx.metadata({
title: params.description,
metadata: {
sessionId: session.id,
sessionId: nextSession.id,
model,
},
})
@@ -111,59 +114,77 @@ export const TaskTool = Tool.define("task", async () => {
const messageID = MessageID.ascending()
function cancel() {
SessionPrompt.cancel(session.id)
SessionPrompt.cancel(nextSession.id)
}
ctx.abort.addEventListener("abort", cancel)
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel))
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt)
const result = await SessionPrompt.prompt({
messageID,
sessionID: session.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: agent.name,
tools: {
...(hasTodoWritePermission ? {} : { todowrite: false }),
...(hasTaskPermission ? {} : { task: false }),
...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
},
parts: promptParts,
})
return yield* Effect.acquireUseRelease(
Effect.sync(() => {
ctx.abort.addEventListener("abort", cancel)
}),
() =>
Effect.gen(function* () {
const parts = yield* Effect.promise(() => SessionPrompt.resolvePromptParts(params.prompt))
const result = yield* Effect.promise(() =>
SessionPrompt.prompt({
messageID,
sessionID: nextSession.id,
model: {
modelID: model.modelID,
providerID: model.providerID,
},
agent: next.name,
tools: {
...(canTodo ? {} : { todowrite: false }),
...(canTask ? {} : { task: false }),
...Object.fromEntries((cfg.experimental?.primary_tools ?? []).map((item) => [item, false])),
},
parts,
}),
)
const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
return {
title: params.description,
metadata: {
sessionId: nextSession.id,
model,
},
output: [
`task_id: ${nextSession.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
result.parts.findLast((item) => item.type === "text")?.text ?? "",
"</task_result>",
].join("\n"),
}
}),
() =>
Effect.sync(() => {
ctx.abort.removeEventListener("abort", cancel)
}),
)
})
const output = [
`task_id: ${session.id} (for resuming to continue this task if needed)`,
"",
"<task_result>",
text,
"</task_result>",
].join("\n")
return {
title: params.description,
metadata: {
sessionId: session.id,
model,
},
output,
}
},
}
})
return {
description: DESCRIPTION,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
return Effect.runPromise(run(params, ctx))
},
}
}),
)
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
const accessibleAgents = agents.filter(
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
const items = yield* Effect.promise(() =>
Agent.list().then((items) => items.filter((item) => item.mode !== "primary")),
)
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const filtered = items.filter((item) => Permission.evaluate("task", item.name, agent.permission).action !== "deny")
const list = filtered.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.map(
(item) => `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
)
.join("\n")
return [`Available agent types and the tools they have access to:`, description].join("\n")
return ["Available agent types and the tools they have access to:", description].join("\n")
})

View File

@@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node"
import { expect, spyOn } from "bun:test"
import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path"
import z from "zod"
@@ -29,7 +29,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log"
@@ -627,11 +626,13 @@ it.live(
"cancel finalizes subtask tool state",
() =>
provideTmpdirInstance(
(dir) =>
() =>
Effect.gen(function* () {
const ready = defer<void>()
const aborted = defer<void>()
const init = spyOn(TaskTool, "init").mockImplementation(async () => ({
const registry = yield* ToolRegistry.Service
const init = registry.named.task.init
registry.named.task.init = async () => ({
description: "task",
parameters: z.object({
description: z.string(),
@@ -653,8 +654,8 @@ it.live(
output: "",
}
},
}))
yield* Effect.addFinalizer(() => Effect.sync(() => init.mockRestore()))
})
yield* Effect.addFinalizer(() => Effect.sync(() => void (registry.named.task.init = init)))
const { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello")

View File

@@ -1,50 +1,412 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Config } from "../../src/config/config"
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
import { Session } from "../../src/session"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskDescription, TaskTool } from "../../src/tool/task"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
const it = testEffect(
Layer.mergeAll(Agent.defaultLayer, Config.defaultLayer, CrossSpawnSpawner.defaultLayer, Session.defaultLayer),
)
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: 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* session.updateMessage(assistant)
return { chat, assistant }
})
function reply(input: Parameters<typeof SessionPrompt.prompt>[0], text: string): MessageV2.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", () => {
test("description sorts subagents by name and is stable across calls", async () => {
await using tmp = await tmpdir({
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
it.live("description sorts subagents by name and is stable across calls", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const first = yield* TaskDescription(build)
const second = yield* TaskDescription(build)
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",
},
},
},
},
})
),
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(TaskDescription(agent))
const second = await Effect.runPromise(TaskDescription(agent))
it.live("description hides denied subagents for the caller", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const agent = yield* Agent.Service
const build = yield* agent.get("build")
const description = yield* TaskDescription(build)
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)
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.live("execute resumes an existing task session from task_id", () =>
provideTmpdirInstance(() =>
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* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "resumed")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
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,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
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}`)
expect(seen?.sessionID).toBe(child.id)
}),
),
)
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
const calls: unknown[] = []
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => reply(input, "done")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const exec = (extra?: { bypassAgentCheck?: boolean }) =>
Effect.promise(() =>
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,
messages: [],
metadata() {},
ask: async (input) => {
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.live("execute creates a child when task_id does not exist", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "created")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
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,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
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}`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
),
)
it.live("execute shapes child permissions for task, todowrite, and primary tools", () =>
provideTmpdirInstance(
() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init())
const resolve = SessionPrompt.resolvePromptParts
const prompt = SessionPrompt.prompt
let seen: Parameters<typeof SessionPrompt.prompt>[0] | undefined
SessionPrompt.resolvePromptParts = async (template) => [{ type: "text", text: template }]
SessionPrompt.prompt = async (input) => {
seen = input
return reply(input, "done")
}
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
SessionPrompt.resolvePromptParts = resolve
SessionPrompt.prompt = prompt
}),
)
const result = yield* Effect.promise(() =>
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,
messages: [],
metadata() {},
ask: async () => {},
},
),
)
const child = yield* sessions.get(result.metadata.sessionId)
expect(child.parentID).toBe(chat.id)
expect(child.permission).toEqual([
{
permission: "todowrite",
pattern: "*",
action: "deny",
},
{
permission: "bash",
pattern: "*",
action: "allow",
},
{
permission: "read",
pattern: "*",
action: "allow",
},
])
expect(seen?.tools).toEqual({
todowrite: false,
bash: false,
read: false,
})
}),
{
config: {
agent: {
reviewer: {
mode: "subagent",
permission: {
task: "allow",
},
},
},
experimental: {
primary_tools: ["bash", "read"],
},
},
},
),
)
})