diff --git a/packages/core/test/plugin/promise.test.ts b/packages/core/test/plugin/promise.test.ts index 04ce1ca8d43..0f77a4e43c9 100644 --- a/packages/core/test/plugin/promise.test.ts +++ b/packages/core/test/plugin/promise.test.ts @@ -20,6 +20,7 @@ import { Project } from "@opencode-ai/core/project" import { Workspace } from "@opencode-ai/core/workspace" import { AbsolutePath } from "@opencode-ai/core/schema" import { define } from "@opencode-ai/plugin/promise/plugin" +import type { Info } from "@opencode-ai/plugin/promise/tool" import { Money } from "@opencode-ai/schema/money" import type { SessionHooks } from "@opencode-ai/plugin/effect/session" import { testEffect } from "../lib/effect" @@ -645,6 +646,76 @@ describe("fromPromise", () => { }), ) + it.live("adapts listed and retrieved tool executors without invoking them eagerly", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const host = yield* PluginHost.make(plugins) + const calls: string[] = [] + const failure = new Tool.Error({ message: "executor failed" }) + yield* host.tool.transform((draft) => { + draft.add({ + name: "hello", + description: "Hello", + options: { namespace: "acme", codemode: false }, + input: Schema.Struct({ name: Schema.String }), + output: Schema.String, + execute: ({ name }, context) => { + calls.push(name) + if (name === "failure") return Effect.fail(failure) + return context.progress({ name }).pipe(Effect.as({ output: name })) + }, + }) + }) + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-reads", + setup: async (ctx) => { + const tools: Info[] = [] + await ctx.tool.transform((draft) => { + expect(draft.list().map((tool) => tool.id)).toEqual(["acme_hello"]) + tools.push(...draft.list()) + const tool = draft.get("acme_hello") + if (!tool) throw new Error("Tool was not found") + expect(tool.id).toBe("acme_hello") + tools.push(tool) + }) + expect(tools).toHaveLength(2) + expect(calls).toEqual([]) + await Promise.all( + tools.map(async (tool) => { + const progress: Tool.Metadata[] = [] + const context = { + sessionID: Session.ID.make("ses_promise_tool_reads"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_reads"), + id: Tool.CallID.make("call_reads"), + progress: async (update: Tool.Metadata) => { + progress.push(update) + }, + } + expect(await tool.execute({ name: "world" }, context)).toEqual({ output: "world" }) + expect(progress).toEqual([{ name: "world" }]) + await expect(tool.execute({ name: "failure" }, context)).rejects.toBe(failure) + const error = new Error("progress failed") + await expect( + tool.execute( + { name: "world" }, + { + ...context, + progress: async () => { + throw error + }, + }, + ), + ).rejects.toBe(error) + }), + ) + }, + }), + ).effect(host) + }), + ) + it.live("reloads and disposes Promise tools while preserving older snapshots", () => Effect.gen(function* () { const plugins = yield* Plugin.Service @@ -789,6 +860,61 @@ describe("fromPromise", () => { }), ) + it.live("clears deleted tool options while retaining the namespace", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const registry = yield* Tool.Service + const host = yield* PluginHost.make(plugins) + yield* host.tool.transform((draft) => { + draft.add({ + name: "hello", + description: "Hello", + options: { namespace: "acme", codemode: false }, + input: Schema.Struct({}), + output: Schema.String, + execute: () => Effect.succeed({ output: "Hello" }), + }) + }) + const original = yield* registry.snapshot() + expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) + expect(original.codeModeCatalog).toEqual([]) + + yield* PluginPromise.fromPromise( + define({ + id: "promise-tool-options", + setup: async (ctx) => { + await ctx.tool.transform((draft) => { + draft.update("acme_hello", (tool) => { + delete tool.options + }) + expect(draft.get("acme_hello")?.options).toEqual({ namespace: "acme" }) + }) + }, + }), + ).effect(host) + + const snapshot = yield* registry.snapshot() + expect(snapshot.definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect(snapshot.codeModeCatalog?.map((tool) => tool.path)).toEqual(["acme.hello"]) + expect(original.definitions.map((tool) => tool.name)).toEqual(["acme_hello", "execute"]) + expect( + yield* snapshot.execute({ + sessionID: Session.ID.make("ses_promise_tool_options"), + agent: Agent.ID.make("build"), + messageID: SessionMessage.ID.make("msg_promise_tool_options"), + call: { + type: "tool-call", + id: "call_options", + name: "execute", + input: { code: "return await tools.acme.hello({})" }, + }, + }), + ).toMatchObject({ + output: { output: "Hello", toolCalls: [{ tool: "acme.hello", status: "completed" }] }, + }) + }), + ) + it.effect("returns content-only plugin results through Code Mode", () => Effect.gen(function* () { const plugins = yield* Plugin.Service diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 73e79852524..9b0d3c6faab 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -99,19 +99,15 @@ export function fromPromise(plugin: Plugin) { const run = (effect: Effect.Effect) => Effect.runPromiseWith(context)(effect) - const promiseTool = (tool: Tool.Info & { readonly id: string }): Info & { readonly id: string } => { - const execute = tool.execute - return { - ...tool, - execute: (input, context) => - run( - execute(input, { - ...context, - progress: (update) => Effect.promise(() => context.progress(update)), - }), - ), - } - } + const promiseExecutor = + (execute: Tool.Info["execute"]): Info["execute"] => + (input, context) => + run( + execute(input, { + ...context, + progress: (update) => Effect.promise(() => context.progress(update)), + }), + ) const adaptApiMethod = ( endpoint: HttpApiEndpoint.Top, @@ -310,10 +306,10 @@ export function fromPromise(plugin: Plugin) { register( host.tool.transform((draft) => callback({ - list: () => draft.list().map((tool) => promiseTool(tool)), + list: () => draft.list().map((tool) => ({ ...tool, execute: promiseExecutor(tool.execute) })), get: (id) => { const tool = draft.get(id) - return tool ? promiseTool(tool) : undefined + return tool ? { ...tool, execute: promiseExecutor(tool.execute) } : undefined }, add: (tool: Info) => draft.add({ @@ -322,16 +318,9 @@ export function fromPromise(plugin: Plugin) { }), update: (id, update) => draft.update(id, (tool) => { - const execute = tool.execute const value: Info = { ...tool, - execute: (input, context) => - run( - execute(input, { - ...context, - progress: (update) => Effect.promise(() => context.progress(update)), - }), - ), + execute: promiseExecutor(tool.execute), } update(value) Object.assign(tool, value, {