Compare commits

...

12 Commits

Author SHA1 Message Date
Kit Langton f4faab9bb2 test(effect): cover task resume and permissions 2026-04-04 20:13:09 -04:00
Kit Langton 9a5cf96b7a fix(effect): preserve task cancellation during prompt 2026-04-04 19:52:07 -04:00
Kit Langton 8616818e37 Merge branch 'dev' into refactor/effectify-task-tool 2026-04-04 19:33:26 -04:00
Kit Langton babb46327f refactor(effect): effectify task tool execution
Move the task tool body onto named Effect helpers and keep the Promise bridge at the outer init/execute boundaries. This matches the read tool migration shape while preserving the existing SessionPrompt runtime flow.
2026-04-04 12:14:42 -04:00
Kit Langton 78f7258c6d refactor(effect): build task tool from agent services 2026-04-04 12:14:42 -04:00
Kit Langton baff53b759 refactor(effect): keep read path handling in app filesystem
Move the repaired Windows path normalization and not-found handling back behind AppFileSystem helpers so the read tool stays on the service abstraction end-to-end. Keep external-directory checks on the same path helper family for consistency.
2026-04-04 12:14:11 -04:00
Kit Langton b15f1593c0 refactor(effect): scope read tool warmup
Capture Scope.Scope in the read tool effect and fork LSP warmup into that scope instead of using runFork inside Effect.sync. This keeps the fire-and-forget behavior while matching the surrounding Effect patterns.
2026-04-04 12:01:04 -04:00
Kit Langton 98384cd860 test(effect): simplify read tool harness
Reduce helper indirection in the read tool tests by adding a scope-local run helper, reusing a shared permission capture helper, and keeping env-permission assertions inside a single instance scope.
2026-04-04 12:01:04 -04:00
Kit Langton 6a56bd5e79 refactor(effect): simplify read tool boundaries
Keep LSP warmup off the read critical path and use AppFileSystem helpers directly in the read tool. Update the migration note to describe the single-bridge pattern that the tool now follows.
2026-04-04 12:01:04 -04:00
Kit Langton d9a07b5d96 refactor(effect): effectify read tool execution
Move the read tool body onto a named Effect path and keep a single Promise bridge at execute(). This keeps the service graph wiring from the previous commit while reducing runPromise islands inside the tool implementation.
2026-04-04 12:01:04 -04:00
Kit Langton 64f6c66984 refactor(effect): wire read tool through services
Yield AppFileSystem, Instruction, LSP, and FileTime from the read tool effect so the tool closes over real services instead of static facades. Rewrite read tool tests to use the effect test harness and document the migration pattern for effectified tools.
2026-04-04 11:59:21 -04:00
Kit Langton 62f1421120 refactor(effect): move read tool onto defineEffect 2026-04-04 11:57:53 -04:00
4 changed files with 421 additions and 153 deletions

View File

@ -33,6 +33,7 @@ import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Env } from "../env" import { Env } from "../env"
import { Agent as AgentSvc } from "../agent/agent"
import { Question } from "../question" import { Question } from "../question"
import { Todo } from "../session/todo" import { Todo } from "../session/todo"
import { LSP } from "../lsp" import { LSP } from "../lsp"
@ -68,6 +69,7 @@ export namespace ToolRegistry {
| Plugin.Service | Plugin.Service
| Question.Service | Question.Service
| Todo.Service | Todo.Service
| AgentSvc.Service
| LSP.Service | LSP.Service
| FileTime.Service | FileTime.Service
| Instruction.Service | Instruction.Service
@ -237,6 +239,7 @@ export namespace ToolRegistry {
layer.pipe( layer.pipe(
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Plugin.defaultLayer), Layer.provide(Plugin.defaultLayer),
Layer.provide(AgentSvc.defaultLayer),
Layer.provide(Question.defaultLayer), Layer.provide(Question.defaultLayer),
Layer.provide(Todo.defaultLayer), Layer.provide(Todo.defaultLayer),
Layer.provide(LSP.defaultLayer), Layer.provide(LSP.defaultLayer),

View File

@ -1,14 +1,12 @@
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION from "./task.txt" import DESCRIPTION from "./task.txt"
import z from "zod" import z from "zod"
import { Effect } from "effect"
import { Session } from "../session" import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema" import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2" import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt" import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config" import { Config } from "../config/config"
import { Permission } from "@/permission" import { Permission } from "@/permission"
@ -25,87 +23,103 @@ const parameters = z.object({
command: z.string().describe("The command that triggered this task").optional(), command: z.string().describe("The command that triggered this task").optional(),
}) })
export const TaskTool = Tool.define("task", async (ctx) => { export const TaskTool = Tool.defineEffect(
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) "task",
Effect.gen(function* () {
const agent = yield* Agent.Service
const config = yield* Config.Service
// Filter agents by permissions if agent provided const list = Effect.fn("TaskTool.list")(function* (caller?: Tool.InitContext["agent"]) {
const caller = ctx?.agent const items = yield* agent.list().pipe(Effect.map((items) => items.filter((item) => item.mode !== "primary")))
const accessibleAgents = caller const filtered = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny") ? items.filter((item) => Permission.evaluate("task", item.name, caller.permission).action !== "deny")
: agents : items
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name)) return filtered.toSorted((a, b) => a.name.localeCompare(b.name))
})
const description = DESCRIPTION.replace( const desc = Effect.fn("TaskTool.desc")(function* (caller?: Tool.InitContext["agent"]) {
"{agents}", const items = yield* list(caller)
list return DESCRIPTION.replace(
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) "{agents}",
.join("\n"), items
) .map(
return { (item) =>
description, `- ${item.name}: ${item.description ?? "This subagent should only be called manually by the user."}`,
parameters, )
async execute(params: z.infer<typeof parameters>, ctx) { .join("\n"),
const config = await Config.get() )
})
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) { if (!ctx.extra?.bypassAgentCheck) {
await ctx.ask({ yield* Effect.promise(() =>
permission: "task", ctx.ask({
patterns: [params.subagent_type], permission: "task",
always: ["*"], patterns: [params.subagent_type],
metadata: { always: ["*"],
description: params.description, metadata: {
subagent_type: params.subagent_type, description: params.description,
}, subagent_type: params.subagent_type,
}) },
}),
)
} }
const agent = await Agent.get(params.subagent_type) const next = yield* agent.get(params.subagent_type)
if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) if (!next) {
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
}
const hasTaskPermission = agent.permission.some((rule) => rule.permission === "task") const hasTask = next.permission.some((rule) => rule.permission === "task")
const hasTodoWritePermission = agent.permission.some((rule) => rule.permission === "todowrite") const hasTodo = next.permission.some((rule) => rule.permission === "todowrite")
const session = await iife(async () => { const taskID = params.task_id
if (params.task_id) { const session = taskID
const found = await Session.get(SessionID.make(params.task_id)).catch(() => {}) ? yield* Effect.promise(() => {
if (found) return found 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: [
...(hasTodo
? []
: [
{
permission: "todowrite" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(hasTask
? []
: [
{
permission: "task" as const,
pattern: "*" as const,
action: "deny" as const,
},
]),
...(cfg.experimental?.primary_tools?.map((item) => ({
pattern: "*",
action: "allow" as const,
permission: item,
})) ?? []),
],
}),
))
return await Session.create({ const msg = yield* Effect.sync(() => MessageV2.get({ sessionID: ctx.sessionID, messageID: ctx.messageID }))
parentID: ctx.sessionID, if (msg.info.role !== "assistant") return yield* Effect.fail(new Error("Not an assistant message"))
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 model = agent.model ?? { const model = next.model ?? {
modelID: msg.info.modelID, modelID: msg.info.modelID,
providerID: msg.info.providerID, providerID: msg.info.providerID,
} }
@ -113,7 +127,7 @@ export const TaskTool = Tool.define("task", async (ctx) => {
ctx.metadata({ ctx.metadata({
title: params.description, title: params.description,
metadata: { metadata: {
sessionId: session.id, sessionId: nextSession.id,
model, model,
}, },
}) })
@ -121,46 +135,64 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
function cancel() { function cancel() {
SessionPrompt.cancel(session.id) SessionPrompt.cancel(nextSession.id)
} }
ctx.abort.addEventListener("abort", cancel) return yield* Effect.acquireUseRelease(
using _ = defer(() => ctx.abort.removeEventListener("abort", cancel)) Effect.sync(() => {
const promptParts = await SessionPrompt.resolvePromptParts(params.prompt) 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: {
...(hasTodo ? {} : { todowrite: false }),
...(hasTask ? {} : { 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)
}),
)
})
const result = await SessionPrompt.prompt({ return async (ctx) => {
messageID, const description = await Effect.runPromise(desc(ctx?.agent))
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 { return {
title: params.description, description,
metadata: { parameters,
sessionId: session.id, async execute(params: z.infer<typeof parameters>, ctx) {
model, return Effect.runPromise(run(params, ctx))
}, },
output,
} }
}, }
} }),
}) )

View File

@ -1,5 +1,5 @@
import { NodeFileSystem } from "@effect/platform-node" 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 { Cause, Effect, Exit, Fiber, Layer } from "effect"
import path from "path" import path from "path"
import z from "zod" import z from "zod"
@ -13,7 +13,6 @@ import { MCP } from "../../src/mcp"
import { Permission } from "../../src/permission" import { Permission } from "../../src/permission"
import { Plugin } from "../../src/plugin" import { Plugin } from "../../src/plugin"
import { Provider as ProviderSvc } from "../../src/provider/provider" import { Provider as ProviderSvc } from "../../src/provider/provider"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema" import { ModelID, ProviderID } from "../../src/provider/schema"
import { Question } from "../../src/question" import { Question } from "../../src/question"
import { Todo } from "../../src/session/todo" import { Todo } from "../../src/session/todo"
@ -29,7 +28,6 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status" import { SessionStatus } from "../../src/session/status"
import { Shell } from "../../src/shell/shell" import { Shell } from "../../src/shell/shell"
import { Snapshot } from "../../src/snapshot" import { Snapshot } from "../../src/snapshot"
import { TaskTool } from "../../src/tool/task"
import { ToolRegistry } from "../../src/tool/registry" import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate" import { Truncate } from "../../src/tool/truncate"
import { Log } from "../../src/util/log" import { Log } from "../../src/util/log"
@ -627,11 +625,13 @@ it.live(
"cancel finalizes subtask tool state", "cancel finalizes subtask tool state",
() => () =>
provideTmpdirInstance( provideTmpdirInstance(
(dir) => () =>
Effect.gen(function* () { Effect.gen(function* () {
const ready = defer<void>() const ready = defer<void>()
const aborted = 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", description: "task",
parameters: z.object({ parameters: z.object({
description: z.string(), description: z.string(),
@ -641,6 +641,13 @@ it.live(
command: z.string().optional(), command: z.string().optional(),
}), }),
execute: async (_args, ctx) => { execute: async (_args, ctx) => {
ctx.metadata({
title: "inspect bug",
metadata: {
sessionId: SessionID.make("task"),
model: ref,
},
})
ready.resolve() ready.resolve()
ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true })
await new Promise<void>(() => {}) await new Promise<void>(() => {})
@ -653,8 +660,8 @@ it.live(
output: "", 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 { prompt, chat } = yield* boot()
const msg = yield* user(chat.id, "hello") const msg = yield* user(chat.id, "hello")
@ -673,11 +680,19 @@ it.live(
expect(taskMsg?.info.role).toBe("assistant") expect(taskMsg?.info.role).toBe("assistant")
if (!taskMsg || taskMsg.info.role !== "assistant") return if (!taskMsg || taskMsg.info.role !== "assistant") return
const tool = toolPart(taskMsg.parts) const tool = errorTool(taskMsg.parts)
expect(tool?.type).toBe("tool")
if (!tool) return if (!tool) return
expect(tool.state.status).not.toBe("running") expect(tool.state.error).toBe("Cancelled")
expect(tool.state.input).toEqual({
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
})
expect(tool.state.metadata).toEqual({
sessionId: SessionID.make("task"),
model: ref,
})
expect(taskMsg.info.time.completed).toBeDefined() expect(taskMsg.info.time.completed).toBeDefined()
expect(taskMsg.info.finish).toBeDefined() expect(taskMsg.info.finish).toBeDefined()
}), }),

View File

@ -1,49 +1,267 @@
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 { 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 { Instance } from "../../src/project/instance"
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 { TaskTool } from "../../src/tool/task" import { TaskTool } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture" import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => { afterEach(async () => {
await Instance.disposeAll() 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", () => { describe("tool.task", () => {
test("description sorts subagents by name and is stable across calls", async () => { it.live("description sorts subagents by name and is stable across calls", () =>
await using tmp = await tmpdir({ provideTmpdirInstance(
config: { () =>
agent: { Effect.gen(function* () {
zebra: { const agent = yield* Agent.Service
description: "Zebra agent", const build = yield* agent.get("build")
mode: "subagent", const tool = yield* TaskTool
}, const first = yield* Effect.promise(() => tool.init({ agent: build }))
alpha: { const second = yield* Effect.promise(() => tool.init({ agent: build }))
description: "Alpha agent",
mode: "subagent", expect(first.description).toBe(second.description)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)
expect(general).toBeGreaterThan(explore)
expect(zebra).toBeGreaterThan(general)
}),
{
config: {
agent: {
zebra: {
description: "Zebra agent",
mode: "subagent",
},
alpha: {
description: "Alpha agent",
mode: "subagent",
},
}, },
}, },
}, },
}) ),
)
await Instance.provide({ it.live("execute resumes an existing task session from task_id", () =>
directory: tmp.path, provideTmpdirInstance(() =>
fn: async () => { Effect.gen(function* () {
const build = await Agent.get("build") const sessions = yield* Session.Service
const first = await TaskTool.init({ agent: build }) const { chat, assistant } = yield* seed()
const second = await TaskTool.init({ agent: build }) 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
expect(first.description).toBe(second.description) 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 alpha = first.description.indexOf("- alpha: Alpha agent") const result = yield* Effect.promise(() =>
const explore = first.description.indexOf("- explore:") def.execute(
const general = first.description.indexOf("- general:") {
const zebra = first.description.indexOf("- zebra: Zebra agent") 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 () => {},
},
),
)
expect(alpha).toBeGreaterThan(-1) const kids = yield* sessions.children(chat.id)
expect(explore).toBeGreaterThan(alpha) expect(kids).toHaveLength(1)
expect(general).toBeGreaterThan(explore) expect(kids[0]?.id).toBe(child.id)
expect(zebra).toBeGreaterThan(general) expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(seen?.sessionID).toBe(child.id)
}),
),
)
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"],
},
},
}, },
}) ),
}) )
}) })