diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c29733999214..3c9988ea30b0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -600,7 +600,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the subagent_type: task.agent, command: task.command, } - yield* plugin.trigger("tool.execute.before", { tool: "task", sessionID, callID: part.id }, { args: taskArgs }) + yield* plugin.trigger( + "tool.execute.before", + { tool: TaskTool.id, sessionID, callID: part.id }, + { args: taskArgs }, + ) const taskAgent = yield* agents.get(task.agent) if (!taskAgent) { @@ -679,7 +683,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the yield* plugin.trigger( "tool.execute.after", - { tool: "task", sessionID, callID: part.id, args: taskArgs }, + { tool: TaskTool.id, sessionID, callID: part.id, args: taskArgs }, result, ) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 72911051e0ea..63e1a97ea956 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,10 @@ 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 task = yield* TaskTool + const read = yield* ReadTool + const question = yield* QuestionTool + const todo = yield* TodoWriteTool const state = yield* InstanceState.make( Effect.fn("ToolRegistry.state")(function* (ctx) { @@ -90,11 +97,11 @@ export namespace ToolRegistry { parameters: z.object(def.args), description: def.description, execute: async (args, toolCtx) => { - const pluginCtx = { + const pluginCtx: PluginToolContext = { ...toolCtx, directory: ctx.directory, worktree: ctx.worktree, - } as unknown as PluginToolContext + } const result = await def.execute(args as any, pluginCtx) const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent)) return { @@ -132,34 +139,50 @@ export namespace ToolRegistry { } const cfg = yield* config.get() - const question = + const questionEnabled = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL + const tool = yield* Effect.all({ + invalid: Tool.init(InvalidTool), + bash: Tool.init(BashTool), + read: Tool.init(read), + glob: Tool.init(GlobTool), + grep: Tool.init(GrepTool), + edit: Tool.init(EditTool), + write: Tool.init(WriteTool), + task: Tool.init(task), + fetch: Tool.init(WebFetchTool), + todo: Tool.init(todo), + search: Tool.init(WebSearchTool), + code: Tool.init(CodeSearchTool), + skill: Tool.init(SkillTool), + patch: Tool.init(ApplyPatchTool), + question: Tool.init(question), + lsp: Tool.init(LspTool), + plan: Tool.init(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: [ + tool.invalid, + ...(questionEnabled ? [tool.question] : []), + tool.bash, + tool.read, + tool.glob, + tool.grep, + tool.edit, + tool.write, + tool.task, + tool.fetch, + tool.todo, + tool.search, + tool.code, + tool.skill, + tool.patch, + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []), + ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []), + ], } }), ) @@ -208,7 +231,6 @@ export namespace ToolRegistry { id: tool.id, description: [ output.description, - // TODO: remove this hack tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined, tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined, ] @@ -223,7 +245,7 @@ export namespace ToolRegistry { ) }) - return Service.of({ ids, tools, all, fromID }) + return Service.of({ ids, all, named: { task, read }, tools, fromID }) }), ) @@ -234,6 +256,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 07e779f5bd56..73b55a2fbad3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -6,96 +6,101 @@ 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") - - 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() +const id = "task" + +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(), +}) + +export const TaskTool = Tool.defineEffect( + id, + 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: id, + 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 hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") - const hasTodoWritePermission = agent.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 - } - - 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 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 model = agent.model ?? { + const canTask = next.permission.some((rule) => rule.permission === id) + const canTodo = next.permission.some((rule) => rule.permission === "todowrite") + + 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: id, + pattern: "*" as const, + action: "deny" as const, + }, + ]), + ...(cfg.experimental?.primary_tools?.map((item) => ({ + pattern: "*", + action: "allow" as const, + permission: item, + })) ?? []), + ], + }), + )) + + 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 = next.model ?? { modelID: msg.info.modelID, providerID: msg.info.providerID, } @@ -103,7 +108,7 @@ export const TaskTool = Tool.define("task", async () => { ctx.metadata({ title: params.description, metadata: { - sessionId: session.id, + sessionId: nextSession.id, model, }, }) @@ -111,59 +116,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, - }) - - const text = result.parts.findLast((x) => x.type === "text")?.text ?? "" - 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 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, + }), + ) + + 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) + }), + ) + }) + + 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(id, 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/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 6d129f4271b4..66e1b8e78630 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -98,24 +98,27 @@ export namespace Tool { } } - export function define( - id: string, + export function define( + id: ID, init: (() => Promise>) | DefWithoutID, - ): Info { + ): Info & { id: ID } { return { id, init: wrap(id, init), } } - export function defineEffect( - id: string, + export function defineEffect( + id: ID, init: Effect.Effect<(() => Promise>) | DefWithoutID, never, R>, - ): Effect.Effect, never, R> { - return Effect.map(init, (next) => ({ id, init: wrap(id, next) })) + ): Effect.Effect, never, R> & { id: ID } { + return Object.assign( + Effect.map(init, (next) => ({ id, init: wrap(id, next) })), + { id }, + ) } - export function init(info: Info): Effect.Effect { + export function init(info: Info): Effect.Effect { return Effect.gen(function* () { const init = yield* Effect.promise(() => info.init()) return { diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 17689cf274ec..5693e139d77b 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 fe936a242aaf..8ebfa59d2313 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", + 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", + }, }, - alpha: { - description: "Alpha agent", - mode: "subagent", + }, + }, + ), + ) + + 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(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", + }, }, }, }, - }) - - 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)) - - 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) + ), + ) + + 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"], + }, + }, }, - }) - }) + ), + ) })