refactor(tool): make Tool.Info init effectful (#21989)

This commit is contained in:
Kit Langton
2026-04-11 12:33:17 -04:00
committed by GitHub
parent 27190635ea
commit 5ee7edaf9e
16 changed files with 197 additions and 194 deletions

View File

@@ -229,24 +229,24 @@ Still open:
## Tool interface → Effect ## Tool interface → Effect
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires: `Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
1. Migrate each tool to return Effects 1. Migrate each tool body to return Effects
2. Update `Tool.define()` factory to work with Effects 2. Keep `Tool.define()` inputs Effect-native
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing 3. Update remaining callers to `yield*` tool initialization instead of `await`ing
### Tool migration details ### Tool migration details
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools: With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition. - `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body. - Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests. - If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`: Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools. - Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`. - Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production. - Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later. This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info``Effect` cleanup mostly mechanical later.

View File

@@ -454,52 +454,53 @@ export const BashTool = Tool.define(
} }
}) })
return async () => { return () =>
const shell = Shell.acceptable() Effect.sync(() => {
const name = Shell.name(shell) const shell = Shell.acceptable()
const chain = const name = Shell.name(shell)
name === "powershell" const chain =
? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success." name === "powershell"
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." ? "If the commands depend on each other and must run sequentially, avoid '&&' in this shell because Windows PowerShell 5.1 does not support it. Use PowerShell conditionals such as `cmd1; if ($?) { cmd2 }` when later commands must depend on earlier success."
log.info("bash tool using shell", { shell }) : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell })
return { return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform) .replaceAll("${os}", process.platform)
.replaceAll("${shell}", name) .replaceAll("${shell}", name)
.replaceAll("${chaining}", chain) .replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: Parameters, parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) => execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
const cwd = params.workdir const cwd = params.workdir
? yield* resolvePath(params.workdir, Instance.directory, shell) ? yield* resolvePath(params.workdir, Instance.directory, shell)
: Instance.directory : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) { if (params.timeout !== undefined && params.timeout < 0) {
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
} }
const timeout = params.timeout ?? DEFAULT_TIMEOUT const timeout = params.timeout ?? DEFAULT_TIMEOUT
const ps = PS.has(name) const ps = PS.has(name)
const root = yield* parse(params.command, ps) const root = yield* parse(params.command, ps)
const scan = yield* collect(root, cwd, ps, shell) const scan = yield* collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
yield* ask(ctx, scan) yield* ask(ctx, scan)
return yield* run( return yield* run(
{ {
shell, shell,
name, name,
command: params.command, command: params.command,
cwd, cwd,
env: yield* shellEnv(ctx, cwd), env: yield* shellEnv(ctx, cwd),
timeout, timeout,
description: params.description, description: params.description,
}, },
ctx, ctx,
) )
}), }),
} }
} })
}), }),
) )

View File

@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
"multiedit", "multiedit",
Effect.gen(function* () { Effect.gen(function* () {
const editInfo = yield* EditTool const editInfo = yield* EditTool
const edit = yield* Effect.promise(() => editInfo.init()) const edit = yield* editInfo.init()
return { return {
description: DESCRIPTION, description: DESCRIPTION,

View File

@@ -17,84 +17,84 @@ export const SkillTool = Tool.define(
Effect.gen(function* () { Effect.gen(function* () {
const skill = yield* Skill.Service const skill = yield* Skill.Service
const rg = yield* Ripgrep.Service const rg = yield* Ripgrep.Service
return () =>
Effect.gen(function* () {
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
return async () => { const description =
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer))) list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
const description = : [
list.length === 0 "Load a specialized skill that provides domain-specific instructions and workflows.",
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
return {
description,
parameters: Parameters,
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"", "",
info.content.trim(), "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"", "",
`Base directory for this skill: ${base}`, "The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"", "",
"<skill_files>", 'Tool output includes a `<skill_content name="...">` block with the loaded content.',
files, "",
"</skill_files>", "The following skills provide specialized sets of instructions for particular tasks",
"</skill_content>", "Invoke this tool to load a skill when a task matches one of the available skills listed below:",
].join("\n"), "",
metadata: { Skill.fmt(list, { verbose: false }),
name: info.name, ].join("\n")
dir,
}, return {
} description,
}).pipe(Effect.orDie), parameters: Parameters,
} execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
} Effect.gen(function* () {
const info = yield* skill.get(params.name)
if (!info) {
const all = yield* skill.all()
const available = all.map((s) => s.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
yield* ctx.ask({
permission: "skill",
patterns: [params.name],
always: [params.name],
metadata: {},
})
const dir = path.dirname(info.location)
const base = pathToFileURL(dir).href
const limit = 10
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true }).pipe(
Stream.filter((file) => !file.includes("SKILL.md")),
Stream.map((file) => path.resolve(dir, file)),
Stream.take(limit),
Stream.runCollect,
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
)
return {
title: `Loaded skill: ${info.name}`,
output: [
`<skill_content name="${info.name}">`,
`# Skill: ${info.name}`,
"",
info.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: {
name: info.name,
dir,
},
}
}).pipe(Effect.orDie),
}
})
}), }),
) )

View File

@@ -47,9 +47,13 @@ export namespace Tool {
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> { export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string id: string
init: () => Promise<DefWithoutID<Parameters, M>> init: () => Effect.Effect<DefWithoutID<Parameters, M>>
} }
type Init<Parameters extends z.ZodType, M extends Metadata> =
| DefWithoutID<Parameters, M>
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
export type InferParameters<T> = export type InferParameters<T> =
T extends Info<infer P, any> T extends Info<infer P, any>
? z.infer<P> ? z.infer<P>
@@ -66,60 +70,58 @@ export namespace Tool {
? Def<P, M> ? Def<P, M>
: never : never
function wrap<Parameters extends z.ZodType, Result extends Metadata>( function wrap<Parameters extends z.ZodType, Result extends Metadata>(id: string, init: Init<Parameters, Result>) {
id: string, return () =>
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, Effect.gen(function* () {
) { const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
return async () => { const execute = toolInfo.execute
const toolInfo = init instanceof Function ? await init() : { ...init } toolInfo.execute = (args, ctx) =>
const execute = toolInfo.execute Effect.gen(function* () {
toolInfo.execute = (args, ctx) => yield* Effect.try({
Effect.gen(function* () { try: () => toolInfo.parameters.parse(args),
yield* Effect.try({ catch: (error) => {
try: () => toolInfo.parameters.parse(args), if (error instanceof z.ZodError && toolInfo.formatValidationError) {
catch: (error) => { return new Error(toolInfo.formatValidationError(error), { cause: error })
if (error instanceof z.ZodError && toolInfo.formatValidationError) { }
return new Error(toolInfo.formatValidationError(error), { cause: error }) return new Error(
} `The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
return new Error( { cause: error },
`The ${id} tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, )
{ cause: error }, },
) })
}, const result = yield* execute(args, ctx)
}) if (result.metadata.truncated !== undefined) {
const result = yield* execute(args, ctx) return result
if (result.metadata.truncated !== undefined) { }
return result const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
} const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
const agent = yield* Effect.promise(() => Agent.get(ctx.agent)) return {
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent)) ...result,
return { output: truncated.content,
...result, metadata: {
output: truncated.content, ...result.metadata,
metadata: { truncated: truncated.truncated,
...result.metadata, ...(truncated.truncated && { outputPath: truncated.outputPath }),
truncated: truncated.truncated, },
...(truncated.truncated && { outputPath: truncated.outputPath }), }
}, }).pipe(Effect.orDie)
} return toolInfo
}).pipe(Effect.orDie) })
return toolInfo
}
} }
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>( export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
id: ID, id: ID,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>, init: Effect.Effect<Init<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } { ): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
return Object.assign( return Object.assign(
Effect.map(init, (next) => ({ id, init: wrap(id, next) })), Effect.map(init, (init) => ({ id, init: wrap(id, init) })),
{ id }, { id },
) )
} }
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> { export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
return Effect.gen(function* () { return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init()) const init = yield* info.init()
return { return {
...init, ...init,
id: info.id, id: info.id,

View File

@@ -50,7 +50,7 @@ type ToolCtx = typeof baseCtx & {
const execute = async (params: { patchText: string }, ctx: ToolCtx) => { const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
const info = await runtime.runPromise(ApplyPatchTool) const info = await runtime.runPromise(ApplyPatchTool)
const tool = await info.init() const tool = await runtime.runPromise(info.init())
return Effect.runPromise(tool.execute(params, ctx)) return Effect.runPromise(tool.execute(params, ctx))
} }

View File

@@ -19,7 +19,7 @@ const runtime = ManagedRuntime.make(
) )
function initBash() { function initBash() {
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
} }
const ctx = { const ctx = {

View File

@@ -45,7 +45,7 @@ const resolve = () =>
runtime.runPromise( runtime.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
const info = yield* EditTool const info = yield* EditTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}), }),
) )

View File

@@ -10,7 +10,7 @@ import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
function initGrep() { function initGrep() {
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init())))) return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
} }
const ctx = { const ctx = {

View File

@@ -36,7 +36,7 @@ describe("tool.question", () => {
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init()) const tool = yield* toolInfo.init()
const questions = [ const questions = [
{ {
question: "What is your favorite color?", question: "What is your favorite color?",
@@ -64,7 +64,7 @@ describe("tool.question", () => {
Effect.gen(function* () { Effect.gen(function* () {
const question = yield* Question.Service const question = yield* Question.Service
const toolInfo = yield* QuestionTool const toolInfo = yield* QuestionTool
const tool = yield* Effect.promise(() => toolInfo.init()) const tool = yield* toolInfo.init()
const questions = [ const questions = [
{ {
question: "What is your favorite animal?", question: "What is your favorite animal?",

View File

@@ -46,7 +46,7 @@ const it = testEffect(
const init = Effect.fn("ReadToolTest.init")(function* () { const init = Effect.fn("ReadToolTest.init")(function* () {
const info = yield* ReadTool const info = yield* ReadTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}) })
const run = Effect.fn("ReadToolTest.run")(function* ( const run = Effect.fn("ReadToolTest.run")(function* (

View File

@@ -152,7 +152,7 @@ Use this skill.
fn: async () => { fn: async () => {
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer)) const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
const info = await runtime.runPromise(SkillTool) const info = await runtime.runPromise(SkillTool)
const tool = await info.init() const tool = await runtime.runPromise(info.init())
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = [] const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = { const ctx: Tool.Context = {
...baseCtx, ...baseCtx,

View File

@@ -191,7 +191,7 @@ describe("tool.task", () => {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" }) const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
@@ -229,7 +229,7 @@ describe("tool.task", () => {
Effect.gen(function* () { Effect.gen(function* () {
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
const calls: unknown[] = [] const calls: unknown[] = []
const promptOps = stubOps() const promptOps = stubOps()
@@ -278,7 +278,7 @@ describe("tool.task", () => {
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
@@ -318,7 +318,7 @@ describe("tool.task", () => {
const sessions = yield* Session.Service const sessions = yield* Session.Service
const { chat, assistant } = yield* seed() const { chat, assistant } = yield* seed()
const tool = yield* TaskTool const tool = yield* TaskTool
const def = yield* Effect.promise(() => tool.init()) const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) }) const promptOps = stubOps({ onPrompt: (input) => (seen = input) })

View File

@@ -23,23 +23,23 @@ describe("Tool.define", () => {
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original))) const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
await info.init() await Effect.runPromise(info.init())
await info.init() await Effect.runPromise(info.init())
await info.init() await Effect.runPromise(info.init())
expect(original.execute).toBe(originalExecute) expect(original.execute).toBe(originalExecute)
}) })
test("function-defined tool returns fresh objects and is unaffected", async () => { test("effect-defined tool returns fresh objects and is unaffected", async () => {
const info = await Effect.runPromise( const info = await Effect.runPromise(
Tool.define( Tool.define(
"test-fn-tool", "test-fn-tool",
Effect.succeed(() => Promise.resolve(makeTool("test"))), Effect.succeed(() => Effect.succeed(makeTool("test"))),
), ),
) )
const first = await info.init() const first = await Effect.runPromise(info.init())
const second = await info.init() const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })
@@ -47,8 +47,8 @@ describe("Tool.define", () => {
test("object-defined tool returns distinct objects per init() call", async () => { test("object-defined tool returns distinct objects per init() call", async () => {
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test")))) const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
const first = await info.init() const first = await Effect.runPromise(info.init())
const second = await info.init() const second = await Effect.runPromise(info.init())
expect(first).not.toBe(second) expect(first).not.toBe(second)
}) })

View File

@@ -26,7 +26,7 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
function initTool() { function initTool() {
return WebFetchTool.pipe( return WebFetchTool.pipe(
Effect.flatMap((info) => Effect.promise(() => info.init())), Effect.flatMap((info) => info.init()),
Effect.provide(FetchHttpClient.layer), Effect.provide(FetchHttpClient.layer),
Effect.runPromise, Effect.runPromise,
) )

View File

@@ -43,7 +43,7 @@ const it = testEffect(
const init = Effect.fn("WriteToolTest.init")(function* () { const init = Effect.fn("WriteToolTest.init")(function* () {
const info = yield* WriteTool const info = yield* WriteTool
return yield* Effect.promise(() => info.init()) return yield* info.init()
}) })
const run = Effect.fn("WriteToolTest.run")(function* ( const run = Effect.fn("WriteToolTest.run")(function* (