vercelRunOutcome
vercelRunOutcome resolves the outcome of a Vercel streamText response that was piped through Run.pipe. It returns either a terminal RunEndReason you pass to Run.end, or the sentinel 'suspend' telling you to call Run.suspend instead.
It preserves transport-level outcomes ('cancelled', 'error') from the pipe result. When the pipe completed naturally, it awaits Vercel's finishReason and returns 'suspend' for 'tool-calls' (the LLM requested tools the SDK did not auto-execute, so the run should pause for the next client-published tool result), or 'complete' otherwise.
Use it at the end of every Vercel route handler so the lifecycle event you publish matches what actually happened on the LLM side.
1
2
3
4
5
6
7
8
9
10
11
12
import { streamText } from 'ai';
import { vercelRunOutcome } from '@ably/ai-transport/vercel';
const result = streamText({ model, messages, tools });
const pipeResult = await run.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
if (outcome === 'suspend') {
await run.suspend();
} else {
await run.end(outcome);
}Resolve the run outcome
vercelRunOutcome(pipeResult: StreamResult, finishReason: PromiseLike<FinishReason>): Promise<RunEndReason | 'suspend'>The helper applies two rules:
- If
pipeResult.reasonis'cancelled'or'error', return it as-is. The transport already knows the run ended for a non-completion reason. - If
pipeResult.reasonis'complete', awaitfinishReasonand translate:'tool-calls'returns'suspend'. The LLM requested tools the SDK did not auto-execute, so the run should pause for the next client-published tool result.- Any other Vercel finish reason returns
'complete'. - If
finishReasonrejects, classify the rejection: abort-shaped errors return'cancelled'; anything else (for exampleNoOutputGeneratedError) returns'error'.
The rejection guard matters: Vercel AI SDK v6 rejects streamText().finishReason with the abort signal's reason when the stream is aborted before any step completes, and with NoOutputGeneratedError when the model produced nothing at all. Without the guard the rejection would bubble out of the route handler, skip Run.end, and leave the run with no ai-run-end event on the channel.
Parameters
pipeResultrequiredStreamResultRun.pipe.finishReasonrequiredPromiseLike<AI.FinishReason>finishReason promise from a Vercel streamText result.Returns
Promise<RunEndReason | 'suspend'>. Either a terminal reason ('complete', 'cancelled', 'error') to pass to Run.end, or the sentinel 'suspend' telling the caller to invoke Run.suspend so a continuation Invocation can resume the Run.
Example
A Vercel route handler that runs a tool-capable model, pipes the stream, and either suspends (for tool resolution) or ends the run with the correct reason.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import * as Ably from 'ably';
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { Invocation } from '@ably/ai-transport';
import { createAgentSession, vercelRunOutcome } from '@ably/ai-transport/vercel';
const ably = new Ably.Realtime({ key: process.env.ABLY_API_KEY });
export async function POST(req: Request) {
const invocation = Invocation.fromJSON(await req.json());
const session = createAgentSession({
client: ably,
channelName: invocation.sessionName,
});
await session.connect();
const run = session.createRun(invocation, { signal: req.signal });
try {
await run.start();
await run.loadConversation();
const result = streamText({
model: openai('gpt-4'),
messages: run.messages,
tools: myTools,
});
const pipeResult = await run.pipe(result.toUIMessageStream());
const outcome = await vercelRunOutcome(pipeResult, result.finishReason);
if (outcome === 'suspend') {
await run.suspend();
} else {
await run.end(outcome);
}
} finally {
session.close();
}
return Response.json({ invocationId: run.invocationId });
}