From 211d5bb0be2754bf67f2d21244a6b22da2e3bc17 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 23 Oct 2026 00:07:56 -0700 Subject: [PATCH 1/4] Fail Worker creation when AiSdkPlugin would replace another Activity (breaking) The SDK's plugin merge keeps the last Activity of each name. A caller Activity named invokeModel, invokeModelStreaming, invokeEmbeddingModel, -listTools or -callTool, a second AiSdkPlugin with different options, or another integration's MCP Activity with the same server name (GoogleAdkPlugin uses the same MCP names) was replaced without a warning, and the Worker ran the wrong handler. AiSdkPlugin.configureWorker now throws a TypeError ("Duplicate registration of Activity ''") when one of its Activities would replace a different function. Each plugin Activity records the operation it was built for and its provider, or its MCP client factory and idle timeout, so another operation registered under the same name is refused. The check accepts the same plugin instance twice, two instances with the same options, and Activities from createActivities with the same provider. On real Workers, createActivities next to an AiSdkPlugin starts, and so does a repeated AiSdkPlugin on a Worker without Workflows. With workflowsPath, a repeated AiSdkPlugin still fails Worker.create in the bundler, as it did before this change, because each instance adds its polyfill entry. Breaking change: Workers with such a collision used to start and now fail in Worker.create. Rename or remove the other Activity or MCP server. Activity names, inputs and results do not change, so recorded histories replay unchanged. --- typescript/vercel-ai-sdk/README.md | 8 +++ .../src/__tests__/test-ai-sdk.ts | 35 ++++++++++++- typescript/vercel-ai-sdk/src/activities.ts | 4 +- .../vercel-ai-sdk/src/activity-sources.ts | 49 +++++++++++++++++++ typescript/vercel-ai-sdk/src/plugin.ts | 16 +++++- 5 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 typescript/vercel-ai-sdk/src/activity-sources.ts diff --git a/typescript/vercel-ai-sdk/README.md b/typescript/vercel-ai-sdk/README.md index 25334e99..34f12238 100644 --- a/typescript/vercel-ai-sdk/README.md +++ b/typescript/vercel-ai-sdk/README.md @@ -34,4 +34,12 @@ for model, tool, streaming and MCP configuration. That guide currently uses the SDK-owned `@temporalio/ai-sdk` name; this repository owns the new `@temporalio/vercel-ai-sdk` coordinate. +The plugin registers Activities named `invokeModel`, `invokeModelStreaming` and +`invokeEmbeddingModel`, plus `-listTools` and `-callTool` for +each MCP server. If the Worker already has a different Activity with one of +these names (your own Activity, another integration's MCP Activity, or an +`AiSdkPlugin` with different options), `Worker.create` fails with +`Duplicate registration of Activity ''`. Earlier versions kept the last +one without a warning. Rename the other Activity or MCP server. + Development details are in [typescript/README.md](../README.md). diff --git a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts index b4c84993..a67e5c7f 100644 --- a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts +++ b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts @@ -48,7 +48,7 @@ import { } from '@temporalio/interceptors-opentelemetry'; import { workflowInterceptorModules } from '@temporalio/testing'; import { bundleWorkflowCode, DefaultLogger, Worker as CoreWorker } from '@temporalio/worker'; -import type { InjectedSinks } from '@temporalio/worker'; +import type { InjectedSinks, WorkerOptions } from '@temporalio/worker'; import type { BaseContext } from '@temporalio/test-helpers'; import { test as anyTest, @@ -962,6 +962,39 @@ const createTestMcpClient = async () => { return createMCPClient({ transport: clientTransport }); }; +test('configureWorker refuses to replace a different Activity with the same name', (t) => { + const provider = new TestProvider(helloWorkflowGenerator()); + const files = async () => ({}) as any; + const base = { taskQueue: 'tq' } as WorkerOptions; + const duplicate = (name: string) => ({ + instanceOf: TypeError, + message: new RegExp(`^Duplicate registration of Activity '${name}': AiSdkPlugin would replace`), + }); + + // A caller Activity, another instance's provider, or another instance's MCP + // client factory under the same server name is refused. + const callerActivities = { ...base, activities: { invokeModel: async () => 'caller' } }; + t.throws(() => new AiSdkPlugin({ modelProvider: provider }).configureWorker(callerActivities), duplicate('invokeModel')); + const withMcp = new AiSdkPlugin({ modelProvider: provider, mcpClientFactories: { files } }).configureWorker(base); + const otherProvider = new AiSdkPlugin({ modelProvider: new TestProvider(helloWorkflowGenerator()) }); + t.throws(() => otherProvider.configureWorker(withMcp), duplicate('invokeModel')); + const otherFactory = new AiSdkPlugin({ modelProvider: provider, mcpClientFactories: { files: async () => ({}) as any } }); + t.throws(() => otherFactory.configureWorker(withMcp), duplicate('files-listTools')); + // Another operation built from the same provider, registered under this name. + const aliased = { ...base, activities: { invokeModel: (createActivities(provider) as any).invokeEmbeddingModel } }; + t.throws(() => new AiSdkPlugin({ modelProvider: provider }).configureWorker(aliased), duplicate('invokeModel')); + + // configureWorker accepts the same instance twice (NativeConnection and + // Worker), the same options twice, and createActivities with the same + // provider. With workflowsPath, Worker.create still rejects a repeated + // AiSdkPlugin in the bundler: each one prepends its polyfill entry. + const plugin = new AiSdkPlugin({ modelProvider: provider, mcpClientFactories: { files } }); + t.notThrows(() => plugin.configureWorker(plugin.configureWorker(base))); + t.notThrows(() => new AiSdkPlugin({ modelProvider: provider, mcpClientFactories: { files } }).configureWorker(withMcp)); + const callerSet = { ...base, activities: createActivities(provider) }; + t.notThrows(() => new AiSdkPlugin({ modelProvider: provider }).configureWorker(callerSet)); +}); + test('MCP tool schema survives activity serialization', async (t) => { const { createWorker, executeWorkflow } = helpers(t); diff --git a/typescript/vercel-ai-sdk/src/activities.ts b/typescript/vercel-ai-sdk/src/activities.ts index 6d67b6a2..e28ebc57 100644 --- a/typescript/vercel-ai-sdk/src/activities.ts +++ b/typescript/vercel-ai-sdk/src/activities.ts @@ -18,6 +18,7 @@ import { Context } from '@temporalio/activity'; import { WorkflowStreamClient } from '@temporalio/workflow-streams/client'; import { msOptionalToNumber, type Duration } from '@temporalio/common/lib/time'; import type { McpClientFactories, McpClientFactory } from './mcp'; +import { withSources } from './activity-sources'; const encoder = new TextEncoder(); @@ -396,12 +397,13 @@ export function createActivities( }); }, }; + withSources(activities, [provider]); if (mcpClientFactories !== undefined) { const idleMs = msOptionalToNumber(options?.mcpConnectionIdleTimeout) ?? MCP_CONNECTION_IDLE_MS; Object.entries(mcpClientFactories).forEach(([name, func]) => { activities = { ...activities, - ...activitiesForName(name, func, idleMs), + ...withSources(activitiesForName(name, func, idleMs), [func, idleMs]), }; }); } diff --git a/typescript/vercel-ai-sdk/src/activity-sources.ts b/typescript/vercel-ai-sdk/src/activity-sources.ts new file mode 100644 index 00000000..2c4faaa9 --- /dev/null +++ b/typescript/vercel-ai-sdk/src/activity-sources.ts @@ -0,0 +1,49 @@ +/** + * Lets the plugin refuse an Activity name that is already taken. The SDK's + * plugin merge (`{...existing, ...param}`) keeps the last Activity of each + * name, so without this check a plugin Activity silently replaces a different + * Activity with the same name. @internal + */ + +/** + * What each plugin Activity is: the name it was built under (its operation) + * followed by what it depends on. Two Activities with the same operation and + * the same dependencies do the same thing, so one may replace the other. + */ +const activitySources = new WeakMap(); + +/** + * Records, for every Activity in `activities`, its own key and `sources`, and + * returns `activities`. The key keeps one operation, registered under another + * operation's name, from passing as that operation. + */ +export function withSources(activities: T, sources: readonly unknown[]): T { + for (const [operation, fn] of Object.entries(activities)) activitySources.set(fn, [operation, ...sources]); + return activities; +} + +/** + * Throws if an Activity in `added` would replace a different Activity with the + * same name in `existing`. The same function, or one built for the same + * operation from the same sources, may replace itself: that is the same plugin + * instance, or one with the same options, registered twice (e.g. on the + * NativeConnection and the Worker). + */ +export function refuseReplacedActivities(plugin: string, existing: object | undefined, added: object): void { + // What the Worker registers: own enumerable keys with function values. + const registered = new Map(Object.entries(existing ?? {}).filter(([, fn]) => typeof fn === 'function')); + for (const [name, fn] of Object.entries(added)) { + const other = registered.get(name); + if (other === undefined || other === fn || sameSources(other, fn)) continue; + throw new TypeError( + `Duplicate registration of Activity '${name}': ${plugin} would replace a different Activity with this name. ` + + `Rename or remove the other registration, or give each ${plugin} on this Worker the same options.` + ); + } +} + +function sameSources(a: object, b: object): boolean { + const x = activitySources.get(a); + const y = activitySources.get(b); + return x !== undefined && y !== undefined && x.length === y.length && x.every((v, i) => v === y[i]); +} diff --git a/typescript/vercel-ai-sdk/src/plugin.ts b/typescript/vercel-ai-sdk/src/plugin.ts index 2397e233..9871c16b 100644 --- a/typescript/vercel-ai-sdk/src/plugin.ts +++ b/typescript/vercel-ai-sdk/src/plugin.ts @@ -1,8 +1,9 @@ import type { ProviderV4 } from '@ai-sdk/provider'; import { SimplePlugin } from '@temporalio/plugin'; -import type { BundleOptions } from '@temporalio/worker'; +import type { BundleOptions, WorkerOptions } from '@temporalio/worker'; import type { Duration } from '@temporalio/common/lib/time'; import { createActivities, _evictMcpConnectionsForServer } from './activities'; +import { refuseReplacedActivities } from './activity-sources'; import type { McpClientFactories } from './mcp'; /** @@ -60,6 +61,19 @@ export class AiSdkPlugin extends SimplePlugin { }); } + /** + * Adds the model and MCP Activities to the Worker. + * + * @throws TypeError if the Worker already has a different Activity with one + * of these names: a caller Activity, another integration's Activity, or an + * `AiSdkPlugin` with a different `modelProvider`, MCP client factory or + * `mcpConnectionIdleTimeout`. + */ + override configureWorker(options: WorkerOptions): WorkerOptions { + refuseReplacedActivities('AiSdkPlugin', options.activities, this.options.activities as object); + return super.configureWorker(options); + } + override configureBundler(options: BundleOptions): BundleOptions { // Prepend a polyfill-installer module to the webpack `entry` array so it // evaluates before any other module in the Workflow bundle. Webpack 5 From 58c9ca255396fc8ebf1eb21344335865ffa12703 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Fri, 30 Oct 2026 19:44:06 -0700 Subject: [PATCH 2/4] Refuse Activities that a later plugin replaces, and reword the error The configureWorker check sees only Activities registered before AiSdkPlugin, so a plugin applied after it (later in plugins, or on the NativeConnection) still replaced invokeModel and the other plugin Activities without an error. AiSdkPlugin.runWorker now checks the Worker's final Activities and throws a TypeError if one of its Activities was replaced by a different one. Before throwing, it stops the native Worker and lets run() release it, so the Worker never polls and connection.close() still works. The error now names the fixes that apply: rename or remove the other Activity, run the other integration on its own Worker and task queue, or use one AiSdkPlugin instance on this Worker. The README names StrandsPlugin, says when two instances count as the same, and says which registrations fail at Worker.create and which at worker.run(). --- typescript/vercel-ai-sdk/README.md | 21 +++++++--- .../src/__tests__/test-ai-sdk.ts | 22 ++++++++++- .../vercel-ai-sdk/src/activity-sources.ts | 38 ++++++++++++++++++- typescript/vercel-ai-sdk/src/plugin.ts | 16 +++++++- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/typescript/vercel-ai-sdk/README.md b/typescript/vercel-ai-sdk/README.md index 34f12238..4a596de5 100644 --- a/typescript/vercel-ai-sdk/README.md +++ b/typescript/vercel-ai-sdk/README.md @@ -36,10 +36,21 @@ SDK-owned `@temporalio/ai-sdk` name; this repository owns the new The plugin registers Activities named `invokeModel`, `invokeModelStreaming` and `invokeEmbeddingModel`, plus `-listTools` and `-callTool` for -each MCP server. If the Worker already has a different Activity with one of -these names (your own Activity, another integration's MCP Activity, or an -`AiSdkPlugin` with different options), `Worker.create` fails with -`Duplicate registration of Activity ''`. Earlier versions kept the last -one without a warning. Rename the other Activity or MCP server. +each MCP server. A different Activity with one of these names now fails with +`Duplicate registration of Activity ''`; earlier versions kept the last +one without a warning. If it is registered before `AiSdkPlugin` (in the Worker's +`activities` or by an earlier plugin), `Worker.create` fails. If a plugin +applied after `AiSdkPlugin` replaces one, `worker.run()` fails before the Worker +polls. `Worker.create` applies the Worker's `plugins` in order, then the +`NativeConnection`'s. + +To fix a collision with your own Activity or an MCP server name, rename one of +them. `StrandsPlugin` (`@temporalio/strands-agents`) also registers +`invokeModel` and `invokeModelStreaming`, and neither plugin can rename them, so +run the two integrations on separate Workers and task queues. Two `AiSdkPlugin` +instances count as the same only when they share the `modelProvider` object +and, for each MCP server name, the same factory function and idle timeout, as +when both are built from one options object. Two equal object literals are +refused. Development details are in [typescript/README.md](../README.md). diff --git a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts index a67e5c7f..34011420 100644 --- a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts +++ b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts @@ -47,7 +47,8 @@ import { OpenTelemetryWorkflowClientInterceptor, } from '@temporalio/interceptors-opentelemetry'; import { workflowInterceptorModules } from '@temporalio/testing'; -import { bundleWorkflowCode, DefaultLogger, Worker as CoreWorker } from '@temporalio/worker'; +import { SimplePlugin } from '@temporalio/plugin'; +import { bundleWorkflowCode, DefaultLogger, NativeConnection, Worker as CoreWorker } from '@temporalio/worker'; import type { InjectedSinks, WorkerOptions } from '@temporalio/worker'; import type { BaseContext } from '@temporalio/test-helpers'; import { @@ -995,6 +996,25 @@ test('configureWorker refuses to replace a different Activity with the same name t.notThrows(() => new AiSdkPlugin({ modelProvider: provider }).configureWorker(callerSet)); }); +test('runWorker refuses an Activity that a later plugin replaced, before polling', async (t) => { + const connection = await NativeConnection.connect({ address: t.context.env.address }); + // StrandsPlugin also registers invokeModel; this plugin stands in for it. + const later = new SimplePlugin({ name: 'later', activities: { invokeModel: async () => 'other' } }); + const worker = await Worker.create({ + connection, + namespace: t.context.env.namespace, + taskQueue: 'test-ai-later-plugin', + plugins: [new AiSdkPlugin({ modelProvider: new TestProvider(helloWorkflowGenerator()) }), later], + }); + await t.throwsAsync(worker.run(), { + instanceOf: TypeError, + message: /^Duplicate registration of Activity 'invokeModel': a plugin applied after AiSdkPlugin replaced/, + }); + t.is(worker.getState(), 'STOPPED'); + // Throws if the Worker still holds the connection. + await connection.close(); +}); + test('MCP tool schema survives activity serialization', async (t) => { const { createWorker, executeWorkflow } = helpers(t); diff --git a/typescript/vercel-ai-sdk/src/activity-sources.ts b/typescript/vercel-ai-sdk/src/activity-sources.ts index 2c4faaa9..66f0dc5c 100644 --- a/typescript/vercel-ai-sdk/src/activity-sources.ts +++ b/typescript/vercel-ai-sdk/src/activity-sources.ts @@ -2,8 +2,9 @@ * Lets the plugin refuse an Activity name that is already taken. The SDK's * plugin merge (`{...existing, ...param}`) keeps the last Activity of each * name, so without this check a plugin Activity silently replaces a different - * Activity with the same name. @internal + * Activity with the same name, or a later plugin silently replaces it. @internal */ +import type { Worker } from '@temporalio/worker'; /** * What each plugin Activity is: the name it was built under (its operation) @@ -37,11 +38,44 @@ export function refuseReplacedActivities(plugin: string, existing: object | unde if (other === undefined || other === fn || sameSources(other, fn)) continue; throw new TypeError( `Duplicate registration of Activity '${name}': ${plugin} would replace a different Activity with this name. ` + - `Rename or remove the other registration, or give each ${plugin} on this Worker the same options.` + remedy(plugin) ); } } +/** + * For `runWorker`, which runs after every plugin's `configureWorker`: throws if + * a plugin applied after this one replaced an Activity in `added` with a + * different one. Before throwing, it stops the Worker so that it never polls, + * and lets `next` release it as `run()` does, so `connection.close()` still + * works. + */ +export async function refuseLaterReplacement( + plugin: string, + worker: Worker, + added: object, + next: (worker: Worker) => Promise +): Promise { + for (const [name, fn] of Object.entries(added)) { + const kept = worker.options.activities.get(name); + if (kept === undefined || kept === fn || sameSources(kept, fn)) continue; + // There is no public way to stop a Worker that has not run yet. + (worker as unknown as { nativeWorker: { initiateShutdown(): void } }).nativeWorker.initiateShutdown(); + await next(worker).catch(() => undefined); + throw new TypeError( + `Duplicate registration of Activity '${name}': a plugin applied after ${plugin} replaced its Activity with this name. ` + + remedy(plugin) + ); + } +} + +function remedy(plugin: string): string { + return ( + 'Rename or remove the other Activity, run the other integration on its own Worker and task queue, ' + + `or use one ${plugin} instance on this Worker.` + ); +} + function sameSources(a: object, b: object): boolean { const x = activitySources.get(a); const y = activitySources.get(b); diff --git a/typescript/vercel-ai-sdk/src/plugin.ts b/typescript/vercel-ai-sdk/src/plugin.ts index 9871c16b..94248108 100644 --- a/typescript/vercel-ai-sdk/src/plugin.ts +++ b/typescript/vercel-ai-sdk/src/plugin.ts @@ -1,9 +1,9 @@ import type { ProviderV4 } from '@ai-sdk/provider'; import { SimplePlugin } from '@temporalio/plugin'; -import type { BundleOptions, WorkerOptions } from '@temporalio/worker'; +import type { BundleOptions, Worker, WorkerOptions } from '@temporalio/worker'; import type { Duration } from '@temporalio/common/lib/time'; import { createActivities, _evictMcpConnectionsForServer } from './activities'; -import { refuseReplacedActivities } from './activity-sources'; +import { refuseLaterReplacement, refuseReplacedActivities } from './activity-sources'; import type { McpClientFactories } from './mcp'; /** @@ -74,6 +74,18 @@ export class AiSdkPlugin extends SimplePlugin { return super.configureWorker(options); } + /** + * Runs the Worker. + * + * @throws TypeError, before the Worker polls, if a plugin applied after this + * one replaced one of its Activities with a different one. `Worker.create` + * applies the Worker's plugins in order, then the `NativeConnection`'s. + */ + override async runWorker(worker: Worker, next: (worker: Worker) => Promise): Promise { + await refuseLaterReplacement('AiSdkPlugin', worker, this.options.activities as object, next); + return super.runWorker(worker, next); + } + override configureBundler(options: BundleOptions): BundleOptions { // Prepend a polyfill-installer module to the webpack `entry` array so it // evaluates before any other module in the Workflow bundle. Webpack 5 From 3f769dbd5957d8f0b75a68332699773a4efa7535 Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Sat, 31 Oct 2026 00:57:21 -0700 Subject: [PATCH 3/4] Start the Worker without yielding when the runWorker check passes Worker.runUntil stops the Worker after its callback only if the Worker is already running. Awaiting the check first meant a callback that throws synchronously, or two plugins with the check, left runUntil polling forever. --- .../src/__tests__/test-ai-sdk.ts | 30 ++++++++++++++++++ .../vercel-ai-sdk/src/activity-sources.ts | 31 ++++++++++++------- typescript/vercel-ai-sdk/src/plugin.ts | 10 ++++-- 3 files changed, 57 insertions(+), 14 deletions(-) diff --git a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts index 34011420..7ef11335 100644 --- a/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts +++ b/typescript/vercel-ai-sdk/src/__tests__/test-ai-sdk.ts @@ -1015,6 +1015,36 @@ test('runWorker refuses an Activity that a later plugin replaced, before polling await connection.close(); }); +test('runWorker starts the Worker at once, so runUntil stops it after its callback', async (t) => { + const connection = await NativeConnection.connect({ address: t.context.env.address }); + // runUntil stops the Worker after its callback only if the Worker is already + // running. A callback that throws synchronously finishes before run() yields. + const alone = await Worker.create({ + connection, + namespace: t.context.env.namespace, + taskQueue: 'test-ai-run-until-throws', + plugins: [new AiSdkPlugin({ modelProvider: new TestProvider(helloWorkflowGenerator()) })], + }); + await t.throwsAsync( + alone.runUntil(() => { + throw new Error('boom'); + }), + { message: 'boom' } + ); + t.is(alone.getState(), 'STOPPED'); + // Each plugin's check runs before the Worker starts; none may yield. + const options = { modelProvider: new TestProvider(helloWorkflowGenerator()) }; + const two = await Worker.create({ + connection, + namespace: t.context.env.namespace, + taskQueue: 'test-ai-run-until-two', + plugins: [new AiSdkPlugin(options), new AiSdkPlugin(options)], + }); + t.is(await two.runUntil(() => Promise.resolve('done')), 'done'); + t.is(two.getState(), 'STOPPED'); + await connection.close(); +}); + test('MCP tool schema survives activity serialization', async (t) => { const { createWorker, executeWorkflow } = helpers(t); diff --git a/typescript/vercel-ai-sdk/src/activity-sources.ts b/typescript/vercel-ai-sdk/src/activity-sources.ts index 66f0dc5c..0646356f 100644 --- a/typescript/vercel-ai-sdk/src/activity-sources.ts +++ b/typescript/vercel-ai-sdk/src/activity-sources.ts @@ -44,29 +44,38 @@ export function refuseReplacedActivities(plugin: string, existing: object | unde } /** - * For `runWorker`, which runs after every plugin's `configureWorker`: throws if - * a plugin applied after this one replaced an Activity in `added` with a - * different one. Before throwing, it stops the Worker so that it never polls, - * and lets `next` release it as `run()` does, so `connection.close()` still - * works. + * For `runWorker`, which runs after every plugin's `configureWorker`: if a + * plugin applied after this one replaced an Activity in `added` with a + * different one, returns a promise that rejects with a `TypeError`. Before + * rejecting, it stops the Worker so that it never polls, and lets `next` + * release it as `run()` does, so `connection.close()` still works. + * + * Otherwise it returns `undefined` without yielding, so the caller can start + * the Worker in the same tick. `Worker.runUntil` calls its callback right + * after `run()` and stops the Worker afterwards only if it is already running; + * a Worker started later polls until something else stops it. */ -export async function refuseLaterReplacement( +export function refuseLaterReplacement( plugin: string, worker: Worker, added: object, next: (worker: Worker) => Promise -): Promise { +): Promise | undefined { for (const [name, fn] of Object.entries(added)) { const kept = worker.options.activities.get(name); if (kept === undefined || kept === fn || sameSources(kept, fn)) continue; - // There is no public way to stop a Worker that has not run yet. - (worker as unknown as { nativeWorker: { initiateShutdown(): void } }).nativeWorker.initiateShutdown(); - await next(worker).catch(() => undefined); - throw new TypeError( + const error = new TypeError( `Duplicate registration of Activity '${name}': a plugin applied after ${plugin} replaced its Activity with this name. ` + remedy(plugin) ); + // There is no public way to stop a Worker that has not run yet. + (worker as unknown as { nativeWorker: { initiateShutdown(): void } }).nativeWorker.initiateShutdown(); + const fail = (): never => { + throw error; + }; + return next(worker).then(fail, fail); } + return undefined; } function remedy(plugin: string): string { diff --git a/typescript/vercel-ai-sdk/src/plugin.ts b/typescript/vercel-ai-sdk/src/plugin.ts index 94248108..33ad7b41 100644 --- a/typescript/vercel-ai-sdk/src/plugin.ts +++ b/typescript/vercel-ai-sdk/src/plugin.ts @@ -81,9 +81,13 @@ export class AiSdkPlugin extends SimplePlugin { * one replaced one of its Activities with a different one. `Worker.create` * applies the Worker's plugins in order, then the `NativeConnection`'s. */ - override async runWorker(worker: Worker, next: (worker: Worker) => Promise): Promise { - await refuseLaterReplacement('AiSdkPlugin', worker, this.options.activities as object, next); - return super.runWorker(worker, next); + override runWorker(worker: Worker, next: (worker: Worker) => Promise): Promise { + // Not async: when the check passes, the Worker starts in this tick, as + // `Worker.runUntil` needs (see `refuseLaterReplacement`). + return ( + refuseLaterReplacement('AiSdkPlugin', worker, this.options.activities as object, next) ?? + super.runWorker(worker, next) + ); } override configureBundler(options: BundleOptions): BundleOptions { From 255fa429210ab51a53fa9e643e71a9f84a96b0ce Mon Sep 17 00:00:00 2001 From: Francesc Campoy Date: Mon, 2 Nov 2026 08:09:24 -0800 Subject: [PATCH 4/4] Say which AiSdkPlugin instances count as the same in the README Equal option literals pass when they share the provider and factory objects; what is refused is a provider or factory created once per plugin. The check also compares only MCP server names both instances register. --- typescript/vercel-ai-sdk/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/typescript/vercel-ai-sdk/README.md b/typescript/vercel-ai-sdk/README.md index 4a596de5..d6626997 100644 --- a/typescript/vercel-ai-sdk/README.md +++ b/typescript/vercel-ai-sdk/README.md @@ -49,8 +49,10 @@ them. `StrandsPlugin` (`@temporalio/strands-agents`) also registers `invokeModel` and `invokeModelStreaming`, and neither plugin can rename them, so run the two integrations on separate Workers and task queues. Two `AiSdkPlugin` instances count as the same only when they share the `modelProvider` object -and, for each MCP server name, the same factory function and idle timeout, as -when both are built from one options object. Two equal object literals are -refused. +and, for each MCP server name both register, the same factory function and idle +timeout. A provider or factory created separately for each instance is refused +even when its settings are equal, for example `createOpenAI({ apiKey })` called +once per plugin, or an `async () => ...` factory written inline in each options +object. Development details are in [typescript/README.md](../README.md).