Просмотр исходного кода

refactor(effect): build task tool from agent services

Kit Langton 1 неделя назад
Родитель
Сommit
186063fbed

+ 2 - 3
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: {

+ 57 - 28
packages/opencode/src/tool/registry.ts

@@ -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),

+ 150 - 129
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")
-
-  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 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(
+  "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 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 === "task")
+      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: "task" as const,
+                      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 +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,
-      })
-
-      const text = result.parts.findLast((x) => x.type === "text")?.text ?? ""
 
-      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 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)`,
+                "",
+                "<task_result>",
+                result.parts.findLast((item) => item.type === "text")?.text ?? "",
+                "</task_result>",
+              ].join("\n"),
+            }
+          }),
+        () =>
+          Effect.sync(() => {
+            ctx.abort.removeEventListener("abort", cancel)
+          }),
+      )
+    })
+
+    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")
   })

+ 7 - 6
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<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")

+ 398 - 36
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<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",
+  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<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"],
+          },
+        },
       },
-    })
-  })
+    ),
+  )
 })