diff --git a/apps/cloud/src/auth/context.ts b/apps/cloud/src/auth/context.ts index d12ec24019..503d282b12 100644 --- a/apps/cloud/src/auth/context.ts +++ b/apps/cloud/src/auth/context.ts @@ -1,7 +1,7 @@ import { Context, Effect, Layer } from "effect"; import { makeUserStore } from "../services/user-store"; import { DbService } from "../services/db"; -import { UserStoreError, withServiceLogging } from "./errors"; +import { UserStoreError, tryPromiseService, withServiceLogging } from "./errors"; // AuthContext is defined in ./middleware.ts to keep middleware-related types together. export { AuthContext } from "./middleware"; @@ -17,7 +17,7 @@ const makeService = (store: RawStore) => ({ withServiceLogging( "user_store", () => new UserStoreError(), - Effect.tryPromise({ try: () => fn(store), catch: (e) => e }), + tryPromiseService(() => fn(store)), ), }); diff --git a/apps/cloud/src/auth/errors.ts b/apps/cloud/src/auth/errors.ts index 51816503c1..17a4d0ec70 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -1,5 +1,5 @@ import { HttpApiSchema } from "@effect/platform"; -import { Effect, Schema } from "effect"; +import { Data, Effect, Schema } from "effect"; export class UserStoreError extends Schema.TaggedError()( "UserStoreError", @@ -13,6 +13,27 @@ export class WorkOSError extends Schema.TaggedError()( HttpApiSchema.annotations({ status: 500 }), ) {} +/** + * Private wrapper used by service adapters that lift Promise APIs into + * Effect. `withServiceLogging` immediately remaps these into a public-facing + * tagged error, so callers never observe this tag directly — its only job is + * to keep the internal failure channel typed instead of `unknown` / `Error`. + */ +export class ServiceAdapterError extends Data.TaggedError( + "ServiceAdapterError", +)<{ + readonly cause: unknown; +}> {} + +/** Lift a Promise-returning function into Effect with a typed failure channel. */ +export const tryPromiseService = ( + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => new ServiceAdapterError({ cause }), + }); + /** * Service-boundary error wrapper. Logs the full Cause chain (drizzle * query/params, pg error codes, nested Error.cause, etc.) via Effect's diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index fc4d217d0b..07d7f64392 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -4,7 +4,7 @@ import { Context, Effect, Layer } from "effect"; import { WorkOS } from "@workos-inc/node/worker"; -import { WorkOSError, withServiceLogging } from "./errors"; +import { WorkOSError, tryPromiseService, withServiceLogging } from "./errors"; import { server } from "../env"; const COOKIE_NAME = "wos-session"; @@ -29,7 +29,7 @@ const make = Effect.gen(function* () { withServiceLogging( "workos", () => new WorkOSError(), - Effect.tryPromise({ try: () => fn(workos), catch: (e) => e }), + tryPromiseService(() => fn(workos)), ); const authenticateSealedSession = (sessionData: string) => diff --git a/apps/cloud/src/mcp-session.ts b/apps/cloud/src/mcp-session.ts index 4a7dd381c2..77b3a386a0 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/apps/cloud/src/mcp-session.ts @@ -3,7 +3,7 @@ // --------------------------------------------------------------------------- import { DurableObject, env } from "cloudflare:workers"; -import { Effect, Layer } from "effect"; +import { Data, Effect, Layer } from "effect"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { WorkerTransport, type TransportState } from "agents/mcp"; @@ -39,15 +39,19 @@ const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); const Services = Layer.mergeAll(DbLive, UserStoreLive); +class OrganizationNotFoundError extends Data.TaggedError( + "OrganizationNotFoundError", +)<{ + readonly organizationId: string; +}> {} + const initSession = (organizationId: string) => Effect.gen(function* () { const users = yield* UserStoreService; const org = yield* users.use((store) => store.getOrganization(organizationId)); if (!org) { - return yield* Effect.fail( - new Error(`Organization ${organizationId} not found`), - ); + return yield* new OrganizationNotFoundError({ organizationId }); } const executor = yield* createOrgExecutor( diff --git a/examples/promise-sdk/tsconfig.json b/examples/promise-sdk/tsconfig.json index 453dd63bf7..8cd04d0acd 100644 --- a/examples/promise-sdk/tsconfig.json +++ b/examples/promise-sdk/tsconfig.json @@ -7,7 +7,8 @@ "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node"] }, "include": ["src"] } diff --git a/packages/core/api/src/handlers/executions.ts b/packages/core/api/src/handlers/executions.ts index bb5c2e55c7..650b7d52e2 100644 --- a/packages/core/api/src/handlers/executions.ts +++ b/packages/core/api/src/handlers/executions.ts @@ -55,11 +55,20 @@ export const ExecutionsHandlers = HttpApiBuilder.group( }); } - const formatted = formatExecuteResult(result); + if (result.status === "completed") { + const formatted = formatExecuteResult(result.result); + return { + text: formatted.text, + structured: formatted.structured, + isError: formatted.isError, + }; + } + + const formatted = formatPausedExecution(result.execution); return { text: formatted.text, structured: formatted.structured, - isError: formatted.isError, + isError: false, }; }), ), diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 412b598e08..3816505455 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -35,12 +35,13 @@ export type ExecutionResult = export type PausedExecution = { readonly id: string; readonly elicitationContext: ElicitationContext; - /** Deferred the caller completes with the user's response to resume the fiber. */ +}; + +/** Internal representation with Effect runtime state for pause/resume. */ +type InternalPausedExecution = PausedExecution & { readonly response: Deferred.Deferred; - /** The fiber running the sandboxed code — stays alive across pause/resume cycles. */ readonly fiber: Fiber.Fiber; - /** Ref to the current pause signal — swapped by resume() before unblocking. */ - readonly pauseSignalRef: Ref.Ref>; + readonly pauseSignalRef: Ref.Ref>; }; export type ResumeResponse = { @@ -288,7 +289,7 @@ const runEffect = (effect: Effect.Effect): Promise => export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionEngine => { const { executor } = config; const codeExecutor = config.codeExecutor ?? makeQuickJsExecutor(); - const pausedExecutions = new Map(); + const pausedExecutions = new Map(); let nextId = 0; /** @@ -298,7 +299,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE */ const awaitCompletionOrPause = ( fiber: Fiber.Fiber, - pauseSignal: Deferred.Deferred, + pauseSignal: Deferred.Deferred, ): Effect.Effect => Effect.race( Fiber.join(fiber).pipe( @@ -321,7 +322,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE // it each time it fires, so resume() can swap in a fresh Deferred // before unblocking the fiber. const pauseSignalRef = yield* Ref.make( - yield* Deferred.make(), + yield* Deferred.make(), ); // Will be set once the fiber is forked. @@ -332,7 +333,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE const responseDeferred = yield* Deferred.make(); const id = `exec_${++nextId}`; - const paused: PausedExecution = { + const paused: InternalPausedExecution = { id, elicitationContext: ctx, response: responseDeferred, @@ -371,7 +372,7 @@ export const createExecutionEngine = (config: ExecutionEngineConfig): ExecutionE // Swap in a fresh pause signal BEFORE unblocking the fiber, so the // next elicitation handler call signals this new Deferred. - const nextSignal = yield* Deferred.make(); + const nextSignal = yield* Deferred.make(); yield* Ref.set(paused.pauseSignalRef, nextSignal); yield* Deferred.succeed(paused.response, { diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index d4fa506a51..345510579b 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -8,7 +8,6 @@ import { inMemoryToolsPlugin, makeTestConfig, tool, - type ToolId, } from "@executor/sdk"; import { createExecutionEngine } from "./engine"; import { describeTool, searchTools } from "./tool-invoker"; @@ -22,7 +21,6 @@ const ContactInput = Schema.Struct({ email: Schema.String, }); -import type { ExecutionResult } from "./engine"; import { FormElicitation } from "@executor/sdk"; const acceptAll = () => Effect.succeed(new ElicitationResponse({ action: "accept" })); @@ -344,8 +342,8 @@ describe("pause/resume with multiple elicitations", () => { engine.executeWithPause(code), ); expect(outcome1.status).toBe("paused"); - if (outcome1.status !== "paused") throw new Error("expected pause"); - expect(outcome1.execution.elicitationContext.request.message).toBe( + const paused1 = outcome1 as Extract; + expect(paused1.execution.elicitationContext.request.message).toBe( "First approval", ); @@ -354,7 +352,7 @@ describe("pause/resume with multiple elicitations", () => { // result or the completion). const outcome2 = yield* Effect.promise(() => Promise.race([ - engine.resume(outcome1.execution.id, { action: "accept" }), + engine.resume(paused1.execution.id, { action: "accept" }), new Promise((_, reject) => setTimeout( () => diff --git a/packages/core/execution/tsconfig.json b/packages/core/execution/tsconfig.json index 72c73fd190..e5564d541d 100644 --- a/packages/core/execution/tsconfig.json +++ b/packages/core/execution/tsconfig.json @@ -13,9 +13,7 @@ "plugins": [ { "name": "@effect/language-service", - "diagnosticSeverity": { - "globalErrorInEffectCatch": "off" - } + "diagnosticSeverity": {} } ] }, diff --git a/packages/core/sdk/src/promise-executor.ts b/packages/core/sdk/src/promise-executor.ts index 0e32751b72..a4ab752da8 100644 --- a/packages/core/sdk/src/promise-executor.ts +++ b/packages/core/sdk/src/promise-executor.ts @@ -1,4 +1,4 @@ -import { Context, Effect } from "effect"; +import { Context, Data, Effect } from "effect"; import { createExecutor as createEffectExecutor, @@ -8,6 +8,9 @@ import { makeInMemoryPolicyEngine, makeInMemorySourceRegistry, ScopeId, + ToolId, + SecretId, + PolicyId, type ToolRegistry as CoreToolRegistry, type SourceRegistry as CoreSourceRegistry, type SecretStore as CoreSecretStore, @@ -31,10 +34,10 @@ import { type SecretProvider as EffectSecretProvider, type SetSecretInput, type Scope, - type ToolId, - type SecretId, + type ToolId as ToolIdType, + type SecretId as SecretIdType, type ScopeId as ScopeIdType, - type PolicyId, + type PolicyId as PolicyIdType, type ToolNotFoundError, type ToolInvocationError, type SecretNotFoundError, @@ -50,8 +53,22 @@ import { const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as Effect.Effect); -const fromPromise = (fn: () => Promise): Effect.Effect => - Effect.tryPromise({ try: fn, catch: (e) => (e instanceof Error ? e : new Error(String(e))) }); +/** + * Tagged error produced by the Promise→Effect adapter layer. User-supplied + * Promise-based store implementations can reject with anything; we wrap the + * raw rejection in `cause` so downstream code can inspect or re-throw it. + */ +class PromiseAdapterError extends Data.TaggedError("PromiseAdapterError")<{ + readonly cause: unknown; +}> {} + +const fromPromise = ( + fn: () => Promise, +): Effect.Effect => + Effect.tryPromise({ + try: fn, + catch: (cause) => new PromiseAdapterError({ cause }), + }); /** * Wrap a promise-returning function as an Effect whose error channel is @@ -62,16 +79,43 @@ const fromPromise = (fn: () => Promise): Effect.Effect => const fromPromiseDying = (fn: () => Promise): Effect.Effect => Effect.orDie(fromPromise(fn)); +/** + * Wrap a promise-returning function as an Effect whose error channel contains + * only the expected tagged errors listed in `tags`. If the promise rejects + * with anything whose `_tag` isn't in the list, the rejection becomes an + * unhandled defect — users of the promise-shaped store interfaces are expected + * to throw the documented tagged error types from core. + */ +const fromPromiseTagged = ( + fn: () => Promise, + tags: readonly E["_tag"][], +): Effect.Effect => + fromPromise(fn).pipe( + Effect.catchAll((err) => { + const cause = err.cause; + if ( + cause !== null && + typeof cause === "object" && + "_tag" in cause && + typeof (cause as { _tag: unknown })._tag === "string" && + (tags as readonly string[]).includes((cause as { _tag: string })._tag) + ) { + return Effect.fail(cause as E); + } + return Effect.die(cause); + }), + ); + // --------------------------------------------------------------------------- // Type derivation — derive Promise-based SDK types from core Effect types // --------------------------------------------------------------------------- /** Replace branded IDs with plain strings in parameter types */ type UnbrandParam = - T extends ToolId ? string : - T extends SecretId ? string : + T extends ToolIdType ? string : + T extends SecretIdType ? string : T extends ScopeIdType ? string : - T extends PolicyId ? string : + T extends PolicyIdType ? string : T extends readonly (infer U)[] ? readonly UnbrandParam[] : T; @@ -106,10 +150,7 @@ export interface InvokeOptions { const toEffectElicitationHandler = (handler: ElicitationHandler) => (ctx: ElicitationContext) => - Effect.tryPromise({ - try: () => handler(ctx), - catch: (e) => e as Error, - }).pipe( + fromPromise(() => handler(ctx)).pipe( Effect.map( (r) => new ElicitationResponseClass({ @@ -117,7 +158,7 @@ const toEffectElicitationHandler = (handler: ElicitationHandler) => content: r.content, }), ), - Effect.catchAll((e) => Effect.die(e)), + Effect.catchAll((e) => Effect.die(e.cause)), ); const toEffectInvokeOptions = (options: InvokeOptions): EffectInvokeOptions => ({ @@ -160,35 +201,41 @@ const effectToPromiseInvokeOptions = (options?: EffectInvokeOptions): InvokeOpti const toEffectInvoker = (invoker: ToolInvoker): EffectToolInvoker => ({ invoke: (toolId, args, options) => - fromPromise(() => invoker.invoke(toolId, args, effectToPromiseInvokeOptions(options))) as Effect.Effect, + fromPromiseTagged( + () => invoker.invoke(toolId, args, effectToPromiseInvokeOptions(options)), + ["ToolInvocationError", "ElicitationDeclinedError"], + ), resolveAnnotations: invoker.resolveAnnotations - ? (toolId) => fromPromise(() => invoker.resolveAnnotations!(toolId)) as Effect.Effect + ? (toolId) => fromPromiseDying(() => invoker.resolveAnnotations!(toolId)) : undefined, }); const toEffectRuntimeHandler = (handler: RuntimeToolHandler): EffectRuntimeToolHandler => ({ invoke: (args, options) => - fromPromise(() => handler.invoke(args, effectToPromiseInvokeOptions(options))) as Effect.Effect, + fromPromiseTagged( + () => handler.invoke(args, effectToPromiseInvokeOptions(options)), + ["ToolInvocationError", "ElicitationDeclinedError"], + ), resolveAnnotations: handler.resolveAnnotations - ? () => fromPromise(() => handler.resolveAnnotations!()) as Effect.Effect + ? () => fromPromiseDying(() => handler.resolveAnnotations!()) : undefined, }); const toEffectSourceManager = (manager: SourceManager): EffectSourceManager => ({ kind: manager.kind, - list: () => fromPromise(() => manager.list()) as Effect.Effect, - remove: (sourceId) => fromPromise(() => manager.remove(sourceId)) as Effect.Effect, - refresh: manager.refresh ? (sourceId) => fromPromise(() => manager.refresh!(sourceId)) as Effect.Effect : undefined, - detect: manager.detect ? (url) => fromPromise(() => manager.detect!(url)) as Effect.Effect : undefined, + list: () => fromPromiseDying(() => manager.list()), + remove: (sourceId) => fromPromiseDying(() => manager.remove(sourceId)), + refresh: manager.refresh ? (sourceId) => fromPromiseDying(() => manager.refresh!(sourceId)) : undefined, + detect: manager.detect ? (url) => fromPromiseDying(() => manager.detect!(url)) : undefined, }); const toEffectSecretProvider = (provider: SecretProvider): EffectSecretProvider => ({ key: provider.key, writable: provider.writable, - get: (key) => fromPromise(() => provider.get(key)) as Effect.Effect, - set: provider.set ? (key, value) => fromPromise(() => provider.set!(key, value)) as Effect.Effect : undefined, - delete: provider.delete ? (key) => fromPromise(() => provider.delete!(key)) as Effect.Effect : undefined, - list: provider.list ? () => fromPromise(() => provider.list!()) as Effect.Effect : undefined, + get: (key) => fromPromiseDying(() => provider.get(key)), + set: provider.set ? (key, value) => fromPromiseDying(() => provider.set!(key, value)) : undefined, + delete: provider.delete ? (key) => fromPromiseDying(() => provider.delete!(key)) : undefined, + list: provider.list ? () => fromPromiseDying(() => provider.list!()) : undefined, }); // --- Reverse adapters (Effect -> Promise) for callbacks handed to user stores --- @@ -200,9 +247,9 @@ const toEffectSecretProvider = (provider: SecretProvider): EffectSecretProvider const toPromiseInvoker = (invoker: EffectToolInvoker): ToolInvoker => ({ invoke: (toolId, args, options) => - run(invoker.invoke(toolId as ToolId, args, toEffectInvokeOptions(options))) as Promise, + run(invoker.invoke(ToolId.make(toolId), args, toEffectInvokeOptions(options))) as Promise, resolveAnnotations: invoker.resolveAnnotations - ? (toolId) => run(invoker.resolveAnnotations!(toolId as ToolId)) + ? (toolId) => run(invoker.resolveAnnotations!(ToolId.make(toolId))) : undefined, }); @@ -241,7 +288,10 @@ const toPromiseSecretProvider = (provider: EffectSecretProvider): SecretProvider const toEffectToolRegistry = (r: ToolRegistry): CoreToolRegistryService => ({ list: (filter) => fromPromiseDying(() => r.list(filter)), schema: (toolId) => - fromPromise(() => r.schema(toolId)) as Effect.Effect, + fromPromiseTagged( + () => r.schema(toolId), + ["ToolNotFoundError"], + ), definitions: () => fromPromiseDying(() => r.definitions()), registerDefinitions: (defs) => fromPromiseDying(() => r.registerDefinitions(defs)), registerRuntimeDefinitions: (defs) => @@ -253,12 +303,13 @@ const toEffectToolRegistry = (r: ToolRegistry): CoreToolRegistryService => ({ resolveAnnotations: (toolId) => fromPromiseDying(() => r.resolveAnnotations(toolId)), invoke: (toolId, args, options) => - fromPromise(() => - r.invoke(toolId, args, effectToPromiseInvokeOptions(options)), - ) as Effect.Effect< - ToolInvocationResult, - ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError - >, + fromPromiseTagged< + ToolNotFoundError | ToolInvocationError | ElicitationDeclinedError, + ToolInvocationResult + >( + () => r.invoke(toolId, args, effectToPromiseInvokeOptions(options)), + ["ToolNotFoundError", "ToolInvocationError", "ElicitationDeclinedError"], + ), register: (tools) => fromPromiseDying(() => r.register(tools)), registerRuntime: (tools) => fromPromiseDying(() => r.registerRuntime(tools)), registerRuntimeHandler: (toolId, effectHandler) => @@ -284,18 +335,27 @@ const toEffectSourceRegistry = (r: SourceRegistry): CoreSourceRegistryService => const toEffectSecretStore = (s: SecretStore): CoreSecretStoreService => ({ list: (scopeId) => fromPromiseDying(() => s.list(scopeId)), get: (secretId) => - fromPromise(() => s.get(secretId)) as Effect.Effect, + fromPromiseTagged( + () => s.get(secretId), + ["SecretNotFoundError"], + ), resolve: (secretId, scopeId) => - fromPromise(() => s.resolve(secretId, scopeId)) as Effect.Effect< - string, - SecretNotFoundError | SecretResolutionError - >, + fromPromiseTagged( + () => s.resolve(secretId, scopeId), + ["SecretNotFoundError", "SecretResolutionError"], + ), status: (secretId, scopeId) => fromPromiseDying(() => s.status(secretId, scopeId)), set: (input) => - fromPromise(() => s.set(input)) as Effect.Effect, + fromPromiseTagged( + () => s.set(input), + ["SecretResolutionError"], + ), remove: (secretId) => - fromPromise(() => s.remove(secretId)) as Effect.Effect, + fromPromiseTagged( + () => s.remove(secretId), + ["SecretNotFoundError"], + ), addProvider: (provider) => fromPromiseDying(() => s.addProvider(toPromiseSecretProvider(provider))), providers: () => fromPromiseDying(() => s.providers()), @@ -304,9 +364,10 @@ const toEffectSecretStore = (s: SecretStore): CoreSecretStoreService => ({ const toEffectPolicyEngine = (p: PolicyEngine): CorePolicyEngineService => ({ list: (scopeId) => fromPromiseDying(() => p.list(scopeId)), check: (input) => - fromPromise(() => - p.check({ scopeId: input.scopeId, toolId: input.toolId }), - ) as Effect.Effect, + fromPromiseTagged( + () => p.check({ scopeId: input.scopeId, toolId: input.toolId }), + ["PolicyDeniedError"], + ), add: (policy) => fromPromiseDying(() => p.add(policy)), remove: (policyId) => fromPromiseDying(() => p.remove(policyId)), }); @@ -350,19 +411,19 @@ const wrapPluginContext = (ctx: EffectPluginContext): PluginContext => ({ scope: ctx.scope, tools: { list: (filter?) => run(ctx.tools.list(filter as any)), - schema: (toolId) => run(ctx.tools.schema(toolId as ToolId)), - invoke: (toolId, args, options) => run(ctx.tools.invoke(toolId as ToolId, args, toEffectInvokeOptions(options))), + schema: (toolId) => run(ctx.tools.schema(ToolId.make(toolId))), + invoke: (toolId, args, options) => run(ctx.tools.invoke(ToolId.make(toolId), args, toEffectInvokeOptions(options))), definitions: () => run(ctx.tools.definitions()), registerDefinitions: (defs) => run(ctx.tools.registerDefinitions(defs)), registerRuntimeDefinitions: (defs) => run(ctx.tools.registerRuntimeDefinitions(defs)), unregisterRuntimeDefinitions: (names) => run(ctx.tools.unregisterRuntimeDefinitions(names)), registerInvoker: (pluginKey, invoker) => run(ctx.tools.registerInvoker(pluginKey, toEffectInvoker(invoker))), - resolveAnnotations: (toolId) => run(ctx.tools.resolveAnnotations(toolId as ToolId)), + resolveAnnotations: (toolId) => run(ctx.tools.resolveAnnotations(ToolId.make(toolId))), register: (tools) => run(ctx.tools.register(tools)), registerRuntime: (tools) => run(ctx.tools.registerRuntime(tools)), - registerRuntimeHandler: (toolId, handler) => run(ctx.tools.registerRuntimeHandler(toolId as ToolId, toEffectRuntimeHandler(handler))), - unregisterRuntime: (toolIds) => run(ctx.tools.unregisterRuntime(toolIds as readonly ToolId[])), - unregister: (toolIds) => run(ctx.tools.unregister(toolIds as readonly ToolId[])), + registerRuntimeHandler: (toolId, handler) => run(ctx.tools.registerRuntimeHandler(ToolId.make(toolId), toEffectRuntimeHandler(handler))), + unregisterRuntime: (toolIds) => run(ctx.tools.unregisterRuntime(toolIds.map((id) => ToolId.make(id)))), + unregister: (toolIds) => run(ctx.tools.unregister(toolIds.map((id) => ToolId.make(id)))), unregisterBySource: (sourceId) => run(ctx.tools.unregisterBySource(sourceId)), }, sources: { @@ -375,20 +436,20 @@ const wrapPluginContext = (ctx: EffectPluginContext): PluginContext => ({ detect: (url) => run(ctx.sources.detect(url)), }, secrets: { - list: (scopeId) => run(ctx.secrets.list(scopeId as ScopeIdType)), - get: (secretId) => run(ctx.secrets.get(secretId as SecretId)), - resolve: (secretId, scopeId) => run(ctx.secrets.resolve(secretId as SecretId, scopeId as ScopeIdType)), - status: (secretId, scopeId) => run(ctx.secrets.status(secretId as SecretId, scopeId as ScopeIdType)), + list: (scopeId) => run(ctx.secrets.list(ScopeId.make(scopeId))), + get: (secretId) => run(ctx.secrets.get(SecretId.make(secretId))), + resolve: (secretId, scopeId) => run(ctx.secrets.resolve(SecretId.make(secretId), ScopeId.make(scopeId))), + status: (secretId, scopeId) => run(ctx.secrets.status(SecretId.make(secretId), ScopeId.make(scopeId))), set: (input) => run(ctx.secrets.set(input as SetSecretInput)), - remove: (secretId) => run(ctx.secrets.remove(secretId as SecretId)), + remove: (secretId) => run(ctx.secrets.remove(SecretId.make(secretId))), addProvider: (provider) => run(ctx.secrets.addProvider(toEffectSecretProvider(provider))), providers: () => run(ctx.secrets.providers()), }, policies: { - list: (scopeId) => run(ctx.policies.list(scopeId as ScopeIdType)), + list: (scopeId) => run(ctx.policies.list(ScopeId.make(scopeId))), check: (input) => run(ctx.policies.check(input as any)), add: (policy) => run(ctx.policies.add(policy)), - remove: (policyId) => run(ctx.policies.remove(policyId as PolicyId)), + remove: (policyId) => run(ctx.policies.remove(PolicyId.make(policyId))), }, }); @@ -425,7 +486,7 @@ const toEffectPlugin = ( extension: handle.extension, close: handle.close ? () => fromPromise(() => handle.close!()) as Effect.Effect : undefined, }; - }) as Effect.Effect, + }) as Effect.Effect, }); // --------------------------------------------------------------------------- @@ -560,11 +621,11 @@ export const createExecutor = async run(executor.secrets.list()), - resolve: (secretId: string) => run(executor.secrets.resolve(secretId as SecretId)), - status: (secretId: string) => run(executor.secrets.status(secretId as SecretId)), + resolve: (secretId: string) => run(executor.secrets.resolve(SecretId.make(secretId))), + status: (secretId: string) => run(executor.secrets.status(SecretId.make(secretId))), set: (input: { readonly id: string; readonly name: string; readonly value: string; readonly provider?: string; readonly purpose?: string }) => run(executor.secrets.set(input as any)), - remove: (secretId: string) => run(executor.secrets.remove(secretId as SecretId)), + remove: (secretId: string) => run(executor.secrets.remove(SecretId.make(secretId))), addProvider: (provider: SecretProvider) => run(executor.secrets.addProvider(toEffectSecretProvider(provider))), providers: () => run(executor.secrets.providers()), }, diff --git a/packages/hosts/mcp/src/server.test.ts b/packages/hosts/mcp/src/server.test.ts index 59cd80b61f..e30bfff6a5 100644 --- a/packages/hosts/mcp/src/server.test.ts +++ b/packages/hosts/mcp/src/server.test.ts @@ -5,19 +5,15 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import type { ClientCapabilities } from "@modelcontextprotocol/sdk/types.js"; -import { FormElicitation, UrlElicitation } from "@executor/sdk"; +import { FormElicitation, ToolId, UrlElicitation } from "@executor/sdk"; import type { ExecutionEngine, ExecutionResult } from "@executor/execution"; import { createExecutorMcpServer } from "./server"; // --------------------------------------------------------------------------- -// Helpers — stub engine +// Helpers // --------------------------------------------------------------------------- -/** - * Creates a fake ExecutionEngine where `execute` and `executeWithPause` - * call into caller-provided functions so each test can control behaviour. - */ const makeStubEngine = (overrides: { execute?: ExecutionEngine["execute"]; executeWithPause?: ExecutionEngine["executeWithPause"]; @@ -25,150 +21,139 @@ const makeStubEngine = (overrides: { description?: string; }): ExecutionEngine => ({ execute: overrides.execute ?? (async () => ({ result: "default" })), - executeWithPause: overrides.executeWithPause ?? + executeWithPause: + overrides.executeWithPause ?? (async () => ({ status: "completed", result: { result: "default" } })), resume: overrides.resume ?? (async () => null), getDescription: async () => overrides.description ?? "test executor", }); -// --------------------------------------------------------------------------- -// Helpers — spin up in-memory client ↔ server -// --------------------------------------------------------------------------- - -type TestHarness = { - client: Client; - close: () => Promise; -}; - -/** - * Connect a real MCP Client to our executor MCP server over in-memory - * transports. The `clientCapabilities` parameter controls whether the - * client advertises elicitation support. - */ -const connect = async ( +/** Connect a real MCP Client to our executor MCP server over in-memory transports. */ +const withClient = async ( engine: ExecutionEngine, - clientCapabilities: ClientCapabilities = {}, -): Promise => { + capabilities: ClientCapabilities, + fn: (client: Client) => Promise, +) => { const mcpServer = await createExecutorMcpServer({ engine }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); const client = new Client( { name: "test-client", version: "1.0.0" }, - { capabilities: clientCapabilities }, + { capabilities }, ); - await mcpServer.connect(serverTransport); await client.connect(clientTransport); + try { + await fn(client); + } finally { + await clientTransport.close(); + await serverTransport.close(); + } +}; - return { - client, - close: async () => { - await clientTransport.close(); - await serverTransport.close(); - }, - }; +const ELICITATION_CAPS: ClientCapabilities = { + elicitation: { form: {}, url: {} }, }; +const FORM_ONLY_CAPS: ClientCapabilities = { elicitation: { form: {} } }; +const NO_CAPS: ClientCapabilities = {}; + +/** Extract the first text content from a callTool result. */ +const textOf = (result: Awaited>): string => + (result.content as Array<{ type: string; text: string }>)[0].text; + +const STUB_TOOL_ID = ToolId.make("t"); + +/** Build a stub paused ExecutionResult with the given id and elicitation request. */ +const makePausedResult = ( + id: string, + request: FormElicitation | UrlElicitation, +): ExecutionResult => ({ + status: "paused", + execution: { + id, + elicitationContext: { toolId: STUB_TOOL_ID, args: {}, request }, + }, +}); + +/** Build an engine whose execute triggers one elicitation and returns the handler's result. */ +const makeElicitingEngine = ( + request: FormElicitation | UrlElicitation, + formatResult: ( + response: { action: string; content?: Record }, + ) => unknown = (r) => r.action, +): ExecutionEngine => + makeStubEngine({ + execute: async (_code, { onElicitation }) => { + const response = await Effect.runPromise( + onElicitation({ + toolId: STUB_TOOL_ID, + args: {}, + request, + }), + ); + return { result: formatResult(response) }; + }, + }); // --------------------------------------------------------------------------- -// Tests — client WITH elicitation support (managed / inline path) +// Client WITH elicitation support (managed / inline path) // --------------------------------------------------------------------------- describe("MCP host server — client with elicitation", () => { it("execute tool calls engine.execute and returns result", async () => { const engine = makeStubEngine({ - execute: async (code) => ({ - result: `ran: ${code}`, - }), - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, + execute: async (code) => ({ result: `ran: ${code}` }), }); - try { - const result = await client.callTool({ name: "execute", arguments: { code: "1+1" } }); + await withClient(engine, ELICITATION_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "1+1" }, + }); expect(result.content).toEqual([{ type: "text", text: "ran: 1+1" }]); expect(result.isError).toBeFalsy(); - } finally { - await close(); - } + }); }); it("form elicitation is bridged from engine to MCP client and back", async () => { - const engine = makeStubEngine({ - execute: async (code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "test-tool" as any, - args: { code }, - request: new FormElicitation({ - message: "Approve this action?", - requestedSchema: { - type: "object", - properties: { - approved: { type: "boolean" }, - }, - }, - }), - }), - ); - return { - result: - response.action === "accept" && response.content?.approved - ? "approved" - : "denied", - }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); + const engine = makeElicitingEngine( + new FormElicitation({ + message: "Approve this action?", + requestedSchema: { + type: "object", + properties: { approved: { type: "boolean" } }, + }, + }), + (r) => + r.action === "accept" && r.content?.approved ? "approved" : "denied", + ); - // Register a client-side handler that auto-accepts - client.setRequestHandler(ElicitRequestSchema, async () => ({ - action: "accept" as const, - content: { approved: true }, - })); + await withClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async () => ({ + action: "accept" as const, + content: { approved: true }, + })); - try { const result = await client.callTool({ name: "execute", arguments: { code: "do-it" }, }); expect(result.content).toEqual([{ type: "text", text: "approved" }]); - } finally { - await close(); - } + }); }); it("form elicitation declined by client → engine sees decline", async () => { - const engine = makeStubEngine({ - execute: async (code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "t" as any, - args: {}, - request: new FormElicitation({ - message: "Accept?", - requestedSchema: {}, - }), - }), - ); - return { result: `action:${response.action}` }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); + const engine = makeElicitingEngine( + new FormElicitation({ message: "Accept?", requestedSchema: {} }), + (r) => `action:${r.action}`, + ); - client.setRequestHandler(ElicitRequestSchema, async () => ({ - action: "decline" as const, - content: {}, - })); + await withClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async () => ({ + action: "decline" as const, + content: {}, + })); - try { const result = await client.callTool({ name: "execute", arguments: { code: "x" }, @@ -176,86 +161,48 @@ describe("MCP host server — client with elicitation", () => { expect(result.content).toEqual([ { type: "text", text: "action:decline" }, ]); - } finally { - await close(); - } + }); }); it("empty form schema gets wrapped with minimal valid schema", async () => { let receivedSchema: unknown; + const engine = makeElicitingEngine( + new FormElicitation({ message: "Just approve", requestedSchema: {} }), + ); + + await withClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + const params = request.params; + if ("requestedSchema" in params) { + receivedSchema = params.requestedSchema; + } + return { action: "accept" as const, content: {} }; + }); - const engine = makeStubEngine({ - execute: async (_code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "t" as any, - args: {}, - request: new FormElicitation({ - message: "Just approve", - requestedSchema: {}, // empty — approval only - }), - }), - ); - return { result: response.action }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); - - client.setRequestHandler(ElicitRequestSchema, async (request) => { - const params = request.params; - if ("requestedSchema" in params) { - receivedSchema = params.requestedSchema; - } - return { action: "accept" as const, content: {} }; - }); - - try { await client.callTool({ name: "execute", arguments: { code: "approve" }, }); - expect(receivedSchema).toEqual({ - type: "object", - properties: {}, - }); - } finally { - await close(); - } + expect(receivedSchema).toEqual({ type: "object", properties: {} }); + }); }); it("UrlElicitation is sent as native mode:url elicitation", async () => { let receivedParams: Record | undefined; + const engine = makeElicitingEngine( + new UrlElicitation({ + message: "Please authenticate", + url: "https://example.com/oauth", + elicitationId: "elic-1", + }), + ); - const engine = makeStubEngine({ - execute: async (_code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "t" as any, - args: {}, - request: new UrlElicitation({ - message: "Please authenticate", - url: "https://example.com/oauth", - elicitationId: "elic-1", - }), - }), - ); - return { result: response.action }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); - - client.setRequestHandler(ElicitRequestSchema, async (request) => { - receivedParams = request.params as Record; - return { action: "accept" as const, content: {} }; - }); + await withClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + receivedParams = request.params as Record; + return { action: "accept" as const, content: {} }; + }); - try { await client.callTool({ name: "execute", arguments: { code: "oauth" }, @@ -264,9 +211,7 @@ describe("MCP host server — client with elicitation", () => { expect(receivedParams?.message).toBe("Please authenticate"); expect(receivedParams?.url).toBe("https://example.com/oauth"); expect(receivedParams?.elicitationId).toBe("elic-1"); - } finally { - await close(); - } + }); }); it("engine error is surfaced as isError result", async () => { @@ -278,113 +223,72 @@ describe("MCP host server — client with elicitation", () => { }), }); - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); - - try { + await withClient(engine, ELICITATION_CAPS, async (client) => { const result = await client.callTool({ name: "execute", arguments: { code: "bad" }, }); expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0].text; - expect(text).toContain("something broke"); - } finally { - await close(); - } + expect(textOf(result)).toContain("something broke"); + }); }); it("resume tool is hidden when client supports elicitation", async () => { - const engine = makeStubEngine({}); - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); - - try { + await withClient(makeStubEngine({}), ELICITATION_CAPS, async (client) => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); expect(names).toContain("execute"); expect(names).not.toContain("resume"); - } finally { - await close(); - } + }); }); }); // --------------------------------------------------------------------------- -// Tests — client with form-only elicitation (uses managed elicitation) +// Client with form-only elicitation (uses managed elicitation) // --------------------------------------------------------------------------- describe("MCP host server — client with form-only elicitation", () => { it("resume tool is hidden when client supports form elicitation", async () => { - const engine = makeStubEngine({}); - const { client, close } = await connect(engine, { - elicitation: { form: {} }, - }); - - try { + await withClient(makeStubEngine({}), FORM_ONLY_CAPS, async (client) => { const { tools } = await client.listTools(); - const names = tools.map((t) => t.name); - expect(names).toContain("execute"); - expect(names).not.toContain("resume"); - } finally { - await close(); - } + expect(tools.map((t) => t.name)).toContain("execute"); + expect(tools.map((t) => t.name)).not.toContain("resume"); + }); }); it("uses managed elicitation path when client supports form", async () => { const engine = makeStubEngine({ - execute: async (code) => ({ - result: `managed: ${code}`, - }), + execute: async (code) => ({ result: `managed: ${code}` }), }); - const { client, close } = await connect(engine, { - elicitation: { form: {} }, - }); - - try { + await withClient(engine, FORM_ONLY_CAPS, async (client) => { const result = await client.callTool({ name: "execute", arguments: { code: "test" }, }); - expect(result.content).toEqual([{ type: "text", text: "managed: test" }]); - } finally { - await close(); - } + expect(result.content).toEqual([ + { type: "text", text: "managed: test" }, + ]); + }); }); it("UrlElicitation falls back to form when client lacks url support", async () => { let receivedMessage: string | undefined; + const engine = makeElicitingEngine( + new UrlElicitation({ + message: "Please authenticate", + url: "https://auth.example.com/oauth", + elicitationId: "elic-1", + }), + ); - const engine = makeStubEngine({ - execute: async (_code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "t" as any, - args: {}, - request: new UrlElicitation({ - message: "Please authenticate", - url: "https://auth.example.com/oauth", - elicitationId: "elic-1", - }), - }), - ); - return { result: response.action }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {} }, // no url support - }); - - client.setRequestHandler(ElicitRequestSchema, async (request) => { - receivedMessage = (request.params as Record).message as string; - return { action: "accept" as const, content: {} }; - }); + await withClient(engine, FORM_ONLY_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async (request) => { + receivedMessage = (request.params as Record) + .message as string; + return { action: "accept" as const, content: {} }; + }); - try { const result = await client.callTool({ name: "execute", arguments: { code: "oauth" }, @@ -392,14 +296,12 @@ describe("MCP host server — client with form-only elicitation", () => { expect(result.content).toEqual([{ type: "text", text: "accept" }]); expect(receivedMessage).toContain("https://auth.example.com/oauth"); expect(receivedMessage).toContain("Please authenticate"); - } finally { - await close(); - } + }); }); }); // --------------------------------------------------------------------------- -// Tests — client WITHOUT elicitation (pause/resume path) +// Client WITHOUT elicitation (pause/resume path) // --------------------------------------------------------------------------- describe("MCP host server — client without elicitation (pause/resume)", () => { @@ -411,76 +313,53 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }), }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "execute", arguments: { code: "ok" }, }); expect(result.content).toEqual([{ type: "text", text: "done" }]); expect(result.isError).toBeFalsy(); - } finally { - await close(); - } + }); }); it("both execute and resume tools are visible", async () => { - const engine = makeStubEngine({}); - const { client, close } = await connect(engine); - - try { + await withClient(makeStubEngine({}), NO_CAPS, async (client) => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); expect(names).toContain("execute"); expect(names).toContain("resume"); - } finally { - await close(); - } + }); }); it("paused execution returns interaction metadata with executionId", async () => { const engine = makeStubEngine({ - executeWithPause: async (): Promise => ({ - status: "paused", - execution: { - id: "exec_42", - elicitationContext: { - toolId: "t" as any, - args: {}, - request: new FormElicitation({ - message: "Need approval", - requestedSchema: { - type: "object", - properties: { ok: { type: "boolean" } }, - }, - }), - }, - resolve: () => {}, - completion: new Promise(() => {}), // never resolves in this test - }, - }), + executeWithPause: async () => + makePausedResult( + "exec_42", + new FormElicitation({ + message: "Need approval", + requestedSchema: { + type: "object", + properties: { ok: { type: "boolean" } }, + }, + }), + ), }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "execute", arguments: { code: "pause-me" }, }); - const text = (result.content as Array<{ type: string; text: string }>)[0].text; - expect(text).toContain("exec_42"); - expect(text).toContain("Need approval"); + expect(textOf(result)).toContain("exec_42"); + expect(textOf(result)).toContain("Need approval"); expect(result.isError).toBeFalsy(); - // structuredContent should contain the executionId const structured = result.structuredContent as Record; expect(structured?.executionId).toBe("exec_42"); expect(structured?.status).toBe("waiting_for_interaction"); - } finally { - await close(); - } + }); }); it("resume tool completes a paused execution", async () => { @@ -493,29 +372,20 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }, }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "resume", - arguments: { - executionId: "exec_1", - action: "accept", - content: "{}", - }, + arguments: { executionId: "exec_1", action: "accept", content: "{}" }, }); expect(result.content).toEqual([ { type: "text", text: "resumed-ok" }, ]); expect(result.isError).toBeFalsy(); - } finally { - await close(); - } + }); }); it("resume tool passes parsed content to engine", async () => { let receivedContent: Record | undefined; - const engine = makeStubEngine({ resume: async (_id, response) => { receivedContent = response.content; @@ -523,9 +393,7 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }, }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { await client.callTool({ name: "resume", arguments: { @@ -535,14 +403,11 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }, }); expect(receivedContent).toEqual({ approved: true, name: "test" }); - } finally { - await close(); - } + }); }); it("resume with empty content passes undefined", async () => { let receivedContent: Record | undefined = { marker: true }; - const engine = makeStubEngine({ resume: async (_id, response) => { receivedContent = response.content; @@ -550,31 +415,19 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }, }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { await client.callTool({ name: "resume", - arguments: { - executionId: "exec_1", - action: "accept", - content: "{}", - }, + arguments: { executionId: "exec_1", action: "accept", content: "{}" }, }); expect(receivedContent).toBeUndefined(); - } finally { - await close(); - } + }); }); it("resume with unknown executionId returns error", async () => { - const engine = makeStubEngine({ - resume: async () => null, - }); - - const { client, close } = await connect(engine); + const engine = makeStubEngine({ resume: async () => null }); - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "resume", arguments: { @@ -584,150 +437,104 @@ describe("MCP host server — client without elicitation (pause/resume)", () => }, }); expect(result.isError).toBe(true); - const text = (result.content as Array<{ type: string; text: string }>)[0].text; - expect(text).toContain("does-not-exist"); - } finally { - await close(); - } + expect(textOf(result)).toContain("does-not-exist"); + }); }); it("paused UrlElicitation includes url and kind in structured output", async () => { const engine = makeStubEngine({ - executeWithPause: async (): Promise => ({ - status: "paused", - execution: { - id: "exec_99", - elicitationContext: { - toolId: "t" as any, - args: {}, - request: new UrlElicitation({ - message: "Please authenticate", - url: "https://auth.example.com/callback", - elicitationId: "elic-url-1", - }), - }, - resolve: () => {}, - completion: new Promise(() => {}), - }, - }), + executeWithPause: async () => + makePausedResult( + "exec_99", + new UrlElicitation({ + message: "Please authenticate", + url: "https://auth.example.com/callback", + elicitationId: "elic-url-1", + }), + ), }); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "execute", arguments: { code: "oauth" }, }); - const text = (result.content as Array<{ type: string; text: string }>)[0].text; - expect(text).toContain("https://auth.example.com/callback"); - expect(text).toContain("exec_99"); + expect(textOf(result)).toContain("https://auth.example.com/callback"); + expect(textOf(result)).toContain("exec_99"); const structured = result.structuredContent as Record; const interaction = structured?.interaction as Record; expect(interaction?.kind).toBe("url"); expect(interaction?.url).toBe("https://auth.example.com/callback"); - } finally { - await close(); - } + }); }); }); // --------------------------------------------------------------------------- -// Tests — elicitation error handling +// Elicitation error handling // --------------------------------------------------------------------------- describe("MCP host server — elicitation error handling", () => { it("elicitInput failure falls back to cancel", async () => { - const engine = makeStubEngine({ - execute: async (_code, { onElicitation }) => { - const response = await Effect.runPromise( - onElicitation({ - toolId: "t" as any, - args: {}, - request: new FormElicitation({ - message: "will fail", - requestedSchema: { - type: "object", - properties: { x: { type: "string" } }, - }, - }), - }), - ); - return { result: `fallback:${response.action}` }; - }, - }); - - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); + const engine = makeElicitingEngine( + new FormElicitation({ + message: "will fail", + requestedSchema: { + type: "object", + properties: { x: { type: "string" } }, + }, + }), + (r) => `fallback:${r.action}`, + ); - // Client throws when it receives the elicitation — server should catch - client.setRequestHandler(ElicitRequestSchema, async () => { - throw new Error("client cannot handle this"); - }); + await withClient(engine, ELICITATION_CAPS, async (client) => { + client.setRequestHandler(ElicitRequestSchema, async () => { + throw new Error("client cannot handle this"); + }); - try { const result = await client.callTool({ name: "execute", arguments: { code: "fail" }, }); - // The server catches the error and returns cancel expect(result.content).toEqual([ { type: "text", text: "fallback:cancel" }, ]); - } finally { - await close(); - } + }); }); }); // --------------------------------------------------------------------------- -// Tests — parseJsonContent edge cases +// Resume content parsing edge cases // --------------------------------------------------------------------------- describe("MCP host server — resume content parsing", () => { - it("array JSON is rejected (not passed as content)", async () => { + const makeResumeEngine = () => { let receivedContent: Record | undefined = { marker: true }; - const engine = makeStubEngine({ resume: async (_id, response) => { receivedContent = response.content; return { status: "completed", result: { result: "ok" } }; }, }); + return { engine, getContent: () => receivedContent }; + }; - const { client, close } = await connect(engine); + it("array JSON is rejected (not passed as content)", async () => { + const { engine, getContent } = makeResumeEngine(); - try { + await withClient(engine, NO_CAPS, async (client) => { await client.callTool({ name: "resume", - arguments: { - executionId: "exec_1", - action: "accept", - content: "[1,2,3]", - }, + arguments: { executionId: "exec_1", action: "accept", content: "[1,2,3]" }, }); - // Array should be rejected — engine receives undefined - expect(receivedContent).toBeUndefined(); - } finally { - await close(); - } + expect(getContent()).toBeUndefined(); + }); }); it("invalid JSON is handled gracefully (not thrown)", async () => { - let receivedContent: Record | undefined = { marker: true }; - - const engine = makeStubEngine({ - resume: async (_id, response) => { - receivedContent = response.content; - return { status: "completed", result: { result: "ok" } }; - }, - }); + const { engine, getContent } = makeResumeEngine(); - const { client, close } = await connect(engine); - - try { + await withClient(engine, NO_CAPS, async (client) => { const result = await client.callTool({ name: "resume", arguments: { @@ -736,27 +543,23 @@ describe("MCP host server — resume content parsing", () => { content: "not-valid-json", }, }); - // Should not crash — invalid JSON treated as undefined content - expect(receivedContent).toBeUndefined(); + expect(getContent()).toBeUndefined(); expect(result.isError).toBeFalsy(); - } finally { - await close(); - } + }); }); }); // --------------------------------------------------------------------------- -// Tests — multiple elicitations in a single execution +// Multiple elicitations in a single execution // --------------------------------------------------------------------------- describe("MCP host server — multiple elicitations", () => { it("engine can elicit multiple times during a single execute call", async () => { const engine = makeStubEngine({ execute: async (_code, { onElicitation }) => { - // First elicitation — ask for name const r1 = await Effect.runPromise( onElicitation({ - toolId: "t" as any, + toolId: STUB_TOOL_ID, args: {}, request: new FormElicitation({ message: "What is your name?", @@ -768,10 +571,9 @@ describe("MCP host server — multiple elicitations", () => { }), ); - // Second elicitation — ask for confirmation const r2 = await Effect.runPromise( onElicitation({ - toolId: "t" as any, + toolId: STUB_TOOL_ID, args: {}, request: new FormElicitation({ message: `Confirm: ${r1.content?.name}?`, @@ -789,20 +591,16 @@ describe("MCP host server — multiple elicitations", () => { }, }); - const { client, close } = await connect(engine, { - elicitation: { form: {}, url: {} }, - }); - - let callCount = 0; - client.setRequestHandler(ElicitRequestSchema, async () => { - callCount++; - if (callCount === 1) { - return { action: "accept" as const, content: { name: "Alice" } }; - } - return { action: "accept" as const, content: { confirmed: true } }; - }); + await withClient(engine, ELICITATION_CAPS, async (client) => { + let callCount = 0; + client.setRequestHandler(ElicitRequestSchema, async () => { + callCount++; + if (callCount === 1) { + return { action: "accept" as const, content: { name: "Alice" } }; + } + return { action: "accept" as const, content: { confirmed: true } }; + }); - try { const result = await client.callTool({ name: "execute", arguments: { code: "multi" }, @@ -811,8 +609,6 @@ describe("MCP host server — multiple elicitations", () => { { type: "text", text: "name=Alice,confirmed=true" }, ]); expect(callCount).toBe(2); - } finally { - await close(); - } + }); }); }); diff --git a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts index df44ae14f3..b7d16e05b3 100644 --- a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { env } from "cloudflare:workers"; +import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import type { SandboxToolInvoker } from "@executor/codemode-core"; import { ToolDispatcher } from "./executor"; @@ -9,6 +10,10 @@ import { makeDynamicWorkerExecutor } from "./executor"; // Helpers // --------------------------------------------------------------------------- +class TestToolError extends Data.TaggedError("TestToolError")<{ + readonly message: string; +}> {} + const makeInvoker = ( fn: (input: { path: string; args: unknown }) => unknown, ): SandboxToolInvoker => ({ @@ -16,7 +21,7 @@ const makeInvoker = ( }); const failingInvoker = (message: string): SandboxToolInvoker => ({ - invoke: () => Effect.fail(new Error(message)), + invoke: () => Effect.fail(new TestToolError({ message })), }); // --------------------------------------------------------------------------- diff --git a/packages/kernel/runtime-dynamic-worker/tsconfig.json b/packages/kernel/runtime-dynamic-worker/tsconfig.json index 4fcdce91b4..6944444a2a 100644 --- a/packages/kernel/runtime-dynamic-worker/tsconfig.json +++ b/packages/kernel/runtime-dynamic-worker/tsconfig.json @@ -13,9 +13,7 @@ "plugins": [ { "name": "@effect/language-service", - "diagnosticSeverity": { - "globalErrorInEffectCatch": "off" - } + "diagnosticSeverity": {} } ] },