Skip to content

Commit 0b8d822

Browse files
brianstrauchclaude
andauthored
feat(ai-sdk): add streamText/streamObject streaming sample (AI SDK v7) (#497)
* feat(ai-sdk): add streamText/streamObject streaming sample and bump to AI SDK v7 Adds streamingAgent and streamObjectAgent that publish deltas to a WorkflowStream topic, a subscribing client consumer, and an offline test. Migrates workflow imports to the @temporalio/ai-sdk/workflow subpath and bumps ai/@ai-sdk deps to v7. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(ai-sdk): exclude from shared tsconfig copy The ai-sdk sample needs custom module settings (module: commonjs, moduleResolution: node) so the ESM-only AI SDK v7 works from this CommonJS sample. Add it to TSCONFIG_EXCLUDE so copy-shared-files doesn't clobber ai-sdk/tsconfig.json on push. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-sdk): load @ai-sdk/openai lazily to fix Node 22 CI The static top-level `import { openai } from '@ai-sdk/openai'` compiles to require() under the sample's CommonJS build. On Node 22's require(ESM) implementation this throws "Unexpected module status 0" at module-load time, aborting the entire mocha run — even though the workflow test is skipped when OPENAI_API_KEY is unset (as in CI). Node 20 and 24 are unaffected. Load @ai-sdk/openai via a native dynamic import() (wrapped in `new Function` so TypeScript doesn't down-level it to require()), inside the `before` hook so it only runs when the suite isn't skipped. Verified against Node 22.23.1: old code reproduces the failure, new code passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ai-sdk): enable require(ESM) so tests pass on Node < 22.12 The sample's CommonJS mocha/ts-node tests must require() the ESM-only `ai` package (via workflows.ts). That needs require(ESM), which is only enabled by default on Node >= 22.12 / 20.19 / 24. The Windows CI runner uses Node 22.11.0, where require(ESM) is still flagged — so requiring workflows.ts throws ERR_REQUIRE_ESM, mocha falls back to import()-ing the .ts test file, and Node's ESM loader rejects it ("Unknown file extension .ts"). Pass `--node-option experimental-require-module` via mocha (cross-platform, unlike inline NODE_OPTIONS) to enable require(ESM) on older Node. The flag is a no-op/accepted on 20.19, 22.12+, and 24. Verified locally on Node 20.19.4, 22.11.0, 22.23.1, and 24: all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3f2dba1 commit 0b8d822

9 files changed

Lines changed: 402 additions & 351 deletions

File tree

.scripts/copy-shared-files.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const HAS_CHILD_SAMPLES = [
1515
// Some samples have different config files from those in .shared/
1616
// that we don't want to overwrite
1717
const TSCONFIG_EXCLUDE = [
18+
'ai-sdk',
1819
'nextjs-ecommerce-oneclick',
1920
'monorepo-folders',
2021
'fetch-esm',

ai-sdk/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,14 @@ This project demonstrates some uses of the AI SDK inside Temporal.
1515
1. `npm run workflow tools`
1616
1. `npm run workflow mcp`
1717
1. `npm run workflow middleware`
18+
1. `npm run workflow stream`
19+
20+
### Streaming
21+
22+
The `stream` sample shows how to stream model output out of a
23+
Workflow. `streamText` runs the model call in
24+
an activity that publishes each delta onto a
25+
[Workflow Stream](https://docs.temporal.io/develop/typescript/workflows/workflow-streams)
26+
topic. The Workflow hosts the stream (`new WorkflowStream()`) and durably reassembles the
27+
final result, while external consumers subscribe by Workflow id to render the tokens live
28+
as they arrive — see `src/client.ts`.

ai-sdk/package.json

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"start": "ts-node src/worker.ts",
1212
"start.watch": "nodemon src/worker.ts",
1313
"workflow": "ts-node src/client.ts",
14-
"test": "mocha --exit --require ts-node/register --require source-map-support/register src/mocha/*.test.ts"
14+
"test": "mocha --exit --node-option experimental-require-module --require ts-node/register --require source-map-support/register src/mocha/*.test.ts"
1515
},
1616
"nodemonConfig": {
1717
"execMap": {
@@ -23,22 +23,23 @@
2323
]
2424
},
2525
"dependencies": {
26-
"@ai-sdk/openai": "^3.0.0",
27-
"@ai-sdk/provider": "^3.0.0",
28-
"@ai-sdk/mcp": "^1.0.0",
29-
"@modelcontextprotocol/sdk": "^1.10.2",
30-
"@temporalio/activity": "1.20.3",
31-
"@temporalio/ai-sdk": "1.20.3",
32-
"@temporalio/client": "1.20.3",
33-
"@temporalio/envconfig": "1.20.3",
34-
"@temporalio/worker": "1.20.3",
35-
"@temporalio/workflow": "1.20.3",
36-
"ai": "^6.0.0",
26+
"@ai-sdk/openai": "^4.0.0",
27+
"@ai-sdk/provider": "^4.0.0",
28+
"@ai-sdk/mcp": "^2.0.0",
29+
"@modelcontextprotocol/sdk": "^1.25.2",
30+
"@temporalio/activity": "^1.21.0",
31+
"@temporalio/ai-sdk": "^1.21.0",
32+
"@temporalio/client": "^1.21.0",
33+
"@temporalio/envconfig": "^1.21.0",
34+
"@temporalio/worker": "^1.21.0",
35+
"@temporalio/workflow": "^1.21.0",
36+
"@temporalio/workflow-streams": "^1.21.0",
37+
"ai": "^7.0.0",
3738
"nanoid": "3.x",
3839
"zod": "^3.25.76"
3940
},
4041
"devDependencies": {
41-
"@temporalio/testing": "1.20.3",
42+
"@temporalio/testing": "^1.21.0",
4243
"@tsconfig/node22": "^22.0.0",
4344
"@types/mocha": "10.x",
4445
"@types/node": "^22.9.1",

ai-sdk/src/client.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import { Connection, Client } from '@temporalio/client';
22
import { loadClientConnectConfig } from '@temporalio/envconfig';
3-
import { haikuAgent, mcpAgent, middlewareAgent, toolsAgent } from './workflows';
3+
import { WorkflowStreamClient } from '@temporalio/workflow-streams/client';
4+
import {
5+
haikuAgent,
6+
mcpAgent,
7+
middlewareAgent,
8+
streamingAgent,
9+
toolsAgent,
10+
STREAM_TOPIC,
11+
consumerDoneSignal,
12+
} from './workflows';
413
import { nanoid } from 'nanoid';
514

15+
// @@@SNIPSTART typescript-vercel-ai-sdk-streaming-consumer
16+
// Subscribe to a Workflow's stream topic and render each text delta live as it
17+
// is published by the streaming activity. Each item's payload is the
18+
// JSON-encoded AI SDK stream part; `resultType: true` decodes it to raw bytes.
19+
async function renderStream(client: Client, workflowId: string, topic: string): Promise<void> {
20+
const streamClient = WorkflowStreamClient.create(client, workflowId);
21+
for await (const item of streamClient.subscribe<Uint8Array>(topic, 0, { resultType: true })) {
22+
const part = JSON.parse(new TextDecoder().decode(item.data));
23+
if (part.type === 'text-delta') process.stdout.write(part.delta);
24+
if (part.type === 'finish') break;
25+
}
26+
process.stdout.write('\n');
27+
// Acknowledge receipt so the Workflow can complete without racing this final
28+
// poll against its in-memory stream log being discarded.
29+
await client.workflow.getHandle(workflowId).signal(consumerDoneSignal);
30+
}
31+
// @@@SNIPEND
32+
633
async function run() {
734
const args = process.argv;
835
const workflow = args[2] ?? 'haiku';
@@ -14,6 +41,18 @@ async function run() {
1441

1542
let handle;
1643
switch (workflow) {
44+
case 'stream': {
45+
const streamHandle = await client.workflow.start(streamingAgent, {
46+
taskQueue: 'ai-sdk',
47+
args: ['Temporal'],
48+
workflowId: 'workflow-' + nanoid(),
49+
});
50+
console.log(`Started workflow ${streamHandle.workflowId}`);
51+
await renderStream(client, streamHandle.workflowId, STREAM_TOPIC);
52+
console.log(await streamHandle.result());
53+
await connection.close();
54+
return;
55+
}
1756
case 'middleware':
1857
handle = await client.workflow.start(middlewareAgent, {
1958
taskQueue: 'ai-sdk',

ai-sdk/src/mocha/streaming.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { TestWorkflowEnvironment } from '@temporalio/testing';
2+
import { after, before, describe, it } from 'mocha';
3+
import { Worker } from '@temporalio/worker';
4+
import { WorkflowStreamClient } from '@temporalio/workflow-streams/client';
5+
import type {
6+
LanguageModelV4,
7+
LanguageModelV4CallOptions,
8+
LanguageModelV4GenerateResult,
9+
LanguageModelV4StreamPart,
10+
LanguageModelV4StreamResult,
11+
ProviderV4,
12+
} from '@ai-sdk/provider';
13+
import assert from 'assert';
14+
import { streamingAgent, STREAM_TOPIC, consumerDoneSignal } from '../workflows';
15+
import * as activities from '../activities';
16+
import { AiSdkPlugin } from '@temporalio/ai-sdk';
17+
18+
// A deterministic, offline model that streams a fixed set of text deltas so
19+
// these tests need no OPENAI_API_KEY and can assert on exact output.
20+
class MockStreamModel implements LanguageModelV4 {
21+
readonly specificationVersion = 'v4';
22+
readonly provider = 'mock';
23+
readonly modelId = 'mock-model';
24+
private readonly chunks: string[];
25+
26+
constructor(chunks: string[]) {
27+
this.chunks = chunks;
28+
}
29+
30+
get supportedUrls(): Record<string, RegExp[]> {
31+
return {};
32+
}
33+
34+
doGenerate(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
35+
throw new Error('generate not supported by mock');
36+
}
37+
38+
doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
39+
const chunks = this.chunks;
40+
const parts: LanguageModelV4StreamPart[] = [
41+
{ type: 'stream-start', warnings: [] },
42+
{ type: 'text-start', id: 't1' },
43+
...chunks.map((delta): LanguageModelV4StreamPart => ({ type: 'text-delta', id: 't1', delta })),
44+
{ type: 'text-end', id: 't1' },
45+
{
46+
type: 'finish',
47+
finishReason: { unified: 'stop', raw: undefined },
48+
usage: {
49+
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
50+
outputTokens: { total: chunks.length, text: undefined, reasoning: undefined },
51+
},
52+
},
53+
];
54+
return Promise.resolve({
55+
stream: new ReadableStream<LanguageModelV4StreamPart>({
56+
start(controller) {
57+
for (const part of parts) controller.enqueue(part);
58+
controller.close();
59+
},
60+
}),
61+
request: {},
62+
response: {},
63+
});
64+
}
65+
}
66+
67+
function mockProvider(chunks: string[]): ProviderV4 {
68+
return {
69+
specificationVersion: 'v4',
70+
languageModel: () => new MockStreamModel(chunks),
71+
embeddingModel: () => {
72+
throw new Error('not implemented');
73+
},
74+
imageModel: () => {
75+
throw new Error('not implemented');
76+
},
77+
};
78+
}
79+
80+
// Collect the live deltas an external subscriber sees on a topic.
81+
async function collectDeltas(client: any, workflowId: string, topic: string): Promise<string[]> {
82+
const deltas: string[] = [];
83+
const streamClient = WorkflowStreamClient.create(client, workflowId);
84+
for await (const item of streamClient.subscribe<Uint8Array>(topic, 0, { resultType: true })) {
85+
const part = JSON.parse(new TextDecoder().decode(item.data));
86+
if (part.type === 'text-delta') deltas.push(part.delta);
87+
if (part.type === 'finish') break;
88+
}
89+
// Mirror the real consumer: acknowledge receipt so the workflow can complete.
90+
await client.workflow.getHandle(workflowId).signal(consumerDoneSignal);
91+
return deltas;
92+
}
93+
94+
describe('streaming agents', function () {
95+
this.timeout(30_000);
96+
97+
let testEnv: TestWorkflowEnvironment;
98+
99+
before(async () => {
100+
testEnv = await TestWorkflowEnvironment.createLocal();
101+
});
102+
103+
after(async () => {
104+
await testEnv?.teardown();
105+
});
106+
107+
it('streamingAgent publishes live deltas and returns the full text', async () => {
108+
const { client, nativeConnection } = testEnv;
109+
const taskQueue = 'test-stream-text';
110+
const chunks = ['Dur', 'able ', 'streams ', 'of ', 'thought'];
111+
112+
const worker = await Worker.create({
113+
connection: nativeConnection,
114+
plugins: [new AiSdkPlugin({ modelProvider: mockProvider(chunks) })],
115+
taskQueue,
116+
workflowsPath: require.resolve('../workflows'),
117+
activities,
118+
});
119+
120+
await worker.runUntil(async () => {
121+
const handle = await client.workflow.start(streamingAgent, {
122+
args: ['Temporal'],
123+
workflowId: 'test-stream-text-' + Date.now(),
124+
taskQueue,
125+
});
126+
127+
const deltasPromise = collectDeltas(client, handle.workflowId, STREAM_TOPIC);
128+
const result = await handle.result();
129+
const deltas = await deltasPromise;
130+
131+
// The workflow durably reassembles the full text from the replayed stream.
132+
assert.strictEqual(result, chunks.join(''));
133+
// The external subscriber saw the response arrive incrementally.
134+
assert.ok(deltas.length > 1, `expected multiple live deltas, got ${deltas.length}`);
135+
assert.strictEqual(deltas.join(''), chunks.join(''));
136+
});
137+
});
138+
});

ai-sdk/src/mocha/workflows.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import { TestWorkflowEnvironment } from '@temporalio/testing';
2-
import { before, describe, it } from 'mocha';
2+
import { after, before, describe, it } from 'mocha';
33
import { Worker } from '@temporalio/worker';
44
import { haikuAgent } from '../workflows';
55
import * as activities from '../activities';
66
import { AiSdkPlugin } from '@temporalio/ai-sdk';
7-
import { openai } from '@ai-sdk/openai';
87
import assert from 'assert';
98

9+
// `@ai-sdk/openai` is ESM-only. Under this sample's CommonJS build a static
10+
// `import` is emitted as `require()`, which throws on Node 22's require(ESM)
11+
// implementation ("Unexpected module status 0") at module-load time — aborting
12+
// the whole mocha run. Load it lazily via a native dynamic `import()` (wrapped
13+
// in `new Function` so TypeScript doesn't down-level it back to `require`),
14+
// inside `before` so it only runs when this suite isn't skipped.
15+
const importESM = new Function('specifier', 'return import(specifier)') as (
16+
specifier: string,
17+
) => Promise<typeof import('@ai-sdk/openai')>;
18+
1019
const hasOpenAIKey = Boolean(process.env.OPENAI_API_KEY);
1120
const describeWorkflow = hasOpenAIKey ? describe : describe.skip;
1221

@@ -16,9 +25,11 @@ describeWorkflow(
1625
this.timeout(30_000);
1726

1827
let testEnv: TestWorkflowEnvironment;
28+
let openai: (typeof import('@ai-sdk/openai'))['openai'];
1929

2030
before(async () => {
2131
testEnv = await TestWorkflowEnvironment.createLocal();
32+
({ openai } = await importESM('@ai-sdk/openai'));
2233
});
2334

2435
after(async () => {

ai-sdk/src/workflows.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import '@temporalio/ai-sdk/lib/load-polyfills';
2-
import { generateText, stepCountIs, tool, wrapLanguageModel } from 'ai';
3-
import { TemporalMCPClient, temporalProvider } from '@temporalio/ai-sdk';
1+
import { generateText, streamText, stepCountIs, tool, wrapLanguageModel } from 'ai';
2+
import type { LanguageModelMiddleware } from 'ai';
3+
import { TemporalMCPClient, TemporalProvider, temporalProvider } from '@temporalio/ai-sdk/workflow';
4+
import { WorkflowStream } from '@temporalio/workflow-streams/workflow';
45
import type * as activities from './activities';
5-
import { proxyActivities } from '@temporalio/workflow';
6+
import { proxyActivities, condition, defineSignal, setHandler } from '@temporalio/workflow';
67
import z from 'zod';
7-
import { LanguageModelV3Middleware } from '@ai-sdk/provider';
88

99
const { getWeather } = proxyActivities<typeof activities>({
1010
startToCloseTimeout: '1 minute',
@@ -45,8 +45,8 @@ export async function toolsAgent(question: string): Promise<string> {
4545
// @@@SNIPSTART typescript-vercel-ai-sdk-middleware-agent
4646
export async function middlewareAgent(prompt: string): Promise<string> {
4747
const cache = new Map<string, any>();
48-
const middleware: LanguageModelV3Middleware = {
49-
specificationVersion: 'v3',
48+
const middleware: LanguageModelMiddleware = {
49+
specificationVersion: 'v4',
5050
wrapGenerate: async ({ doGenerate, params }) => {
5151
const cacheKey = JSON.stringify(params);
5252
if (cache.has(cacheKey)) {
@@ -73,6 +73,7 @@ export async function middlewareAgent(prompt: string): Promise<string> {
7373
});
7474
return result.text;
7575
}
76+
// @@@SNIPEND
7677

7778
// @@@SNIPSTART typescript-vercel-ai-sdk-mcp-agent
7879
export async function mcpAgent(prompt: string): Promise<string> {
@@ -88,3 +89,61 @@ export async function mcpAgent(prompt: string): Promise<string> {
8889
return result.text;
8990
}
9091
// @@@SNIPEND
92+
93+
// @@@SNIPSTART typescript-vercel-ai-sdk-streaming-topic
94+
// The topic that streamed model deltas are published to. External consumers
95+
// subscribe to this topic by workflow id to receive live tokens as they are
96+
// generated. See `client.ts` for the consumer side.
97+
export const STREAM_TOPIC = 'text-stream';
98+
// @@@SNIPEND
99+
100+
// @@@SNIPSTART typescript-vercel-ai-sdk-consumer-done-signal
101+
// A subscriber sends this signal once it has received the stream's final delta,
102+
// so the workflow knows it is safe to complete. See `client.ts` for the sender.
103+
export const consumerDoneSignal = defineSignal('consumer-done');
104+
// @@@SNIPEND
105+
106+
// A provider whose language-model calls stream their deltas onto STREAM_TOPIC.
107+
// Setting `streamingTopic` enables `doStream`; without it, streaming calls
108+
// throw. Use a distinct topic per concurrent streaming call.
109+
const streamingProvider = new TemporalProvider({
110+
languageModel: { streamingTopic: STREAM_TOPIC },
111+
});
112+
113+
// @@@SNIPSTART typescript-vercel-ai-sdk-streaming-agent
114+
export async function streamingAgent(prompt: string): Promise<string> {
115+
// Host the WorkflowStream as the first statement of the workflow so its
116+
// publish-signal handler is registered before the streaming activity starts
117+
// publishing deltas to it.
118+
new WorkflowStream();
119+
120+
// A subscriber flips this once it has consumed the final delta (see below).
121+
let consumerDone = false;
122+
setHandler(consumerDoneSignal, () => {
123+
consumerDone = true;
124+
});
125+
126+
const result = streamText({
127+
model: streamingProvider.languageModel('gpt-4o-mini'),
128+
prompt,
129+
system: 'You only respond in haikus.',
130+
});
131+
132+
// The model call runs in an activity that publishes each delta to
133+
// STREAM_TOPIC for external subscribers. Inside the workflow the deltas are
134+
// replayed after the activity completes, so this loop durably reassembles
135+
// the full text.
136+
let text = '';
137+
for await (const delta of result.textStream) {
138+
text += delta;
139+
}
140+
141+
// The workflow's stream log lives in memory and is discarded once the run
142+
// completes, which can race a subscriber's final poll. Rather than guess at a
143+
// fixed delay, wait for the subscriber to signal that it received the last
144+
// delta. The timeout is a fallback for when nothing is subscribed, so the run
145+
// can't hang forever.
146+
await condition(() => consumerDone, '10 seconds');
147+
return text;
148+
}
149+
// @@@SNIPEND

0 commit comments

Comments
 (0)