summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-08 19:02:19 -0400
committerGitHub <[email protected]>2026-04-08 19:02:19 -0400
commit38f8714c09d72993534ce33a998955e40e83c74f (patch)
tree4b62e90af23b3bf17836f9ede24ac7231d1c9785 /packages
parent4961d72c0fa23ee23bca9ea59b86a2b13bcf4427 (diff)
downloadopencode-38f8714c09d72993534ce33a998955e40e83c74f.tar.gz
opencode-38f8714c09d72993534ce33a998955e40e83c74f.zip
refactor(effect): build task tool from agent services (#21017)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/session/prompt.ts8
-rw-r--r--packages/opencode/src/tool/registry.ts83
-rw-r--r--packages/opencode/src/tool/task.ts281
-rw-r--r--packages/opencode/src/tool/tool.ts19
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts13
-rw-r--r--packages/opencode/test/tool/task.test.ts434
6 files changed, 627 insertions, 211 deletions
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index c29733999..3c9988ea3 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 72911051e..63e1a97ea 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<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,10 @@ 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 task = yield* TaskTool
+ const read = yield* ReadTool
+ const question = yield* QuestionTool
+ const todo = yield* TodoWriteTool
const state = yield* InstanceState.make<State>(
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 07e779f5b..73b55a2fb 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<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: 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)`,
- "",
- "<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(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 6d129f427..66e1b8e78 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<Parameters extends z.ZodType, Result extends Metadata>(
- id: string,
+ export function define<Parameters extends z.ZodType, Result extends Metadata, ID extends string = string>(
+ id: ID,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
- ): Info<Parameters, Result> {
+ ): Info<Parameters, Result> & { id: ID } {
return {
id,
init: wrap(id, init),
}
}
- export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
- id: string,
+ export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
+ id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
- ): Effect.Effect<Info<Parameters, Result>, never, R> {
- return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
+ ): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
+ return Object.assign(
+ Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
+ { id },
+ )
}
- export function init(info: Info): Effect.Effect<Def, never, any> {
+ export function init(info: Info): Effect.Effect<Def> {
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 17689cf27..5693e139d 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<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")
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
index fe936a242..8ebfa59d2 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<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"],
+ },
+ },
},
- })
- })
+ ),
+ )
})