diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c297339992..079f51d600 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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: { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 72911051e0..74a0e7378a 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -50,6 +50,10 @@ export namespace ToolRegistry { export interface Interface { readonly ids: () => Effect.Effect readonly all: () => Effect.Effect + 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 = (tool: T | Effect.Effect) => - Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool) + const info = ( + tool: T | Effect.Effect, + ): Effect.Effect => (Effect.isEffect(tool) ? tool : Effect.succeed(tool)) + + const build = ( + tool: T | Effect.Effect, + ): Effect.Effect => 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( 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), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 07e779f5bd..906c265a63 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -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, 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)`, + "", + "", + result.parts.findLast((item) => item.type === "text")?.text ?? "", + "", + ].join("\n"), + } + }), + () => + Effect.sync(() => { + ctx.abort.removeEventListener("abort", cancel) + }), + ) + }) - const output = [ - `task_id: ${session.id} (for resuming to continue this task if needed)`, - "", - "", - text, - "", - ].join("\n") - - return { - title: params.description, - metadata: { - sessionId: session.id, - model, - }, - output, - } - }, - } -}) + return { + description: DESCRIPTION, + parameters, + async execute(params: z.infer, 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") }) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 17689cf274..5693e139d7 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -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() const aborted = defer() - 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") diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index fe936a242a..8ebfa59d23 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -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[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[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[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[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"], + }, + }, + }, + ), + ) })