Files
triggerdotdev--trigger.dev/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts
T
2026-07-13 13:32:57 +08:00

790 lines
29 KiB
TypeScript

import type { UIMessage, UIMessageChunk } from "ai";
import { resourceCatalog } from "@trigger.dev/core/v3";
import type { LocalsKey } from "@trigger.dev/core/v3";
import { runInMockTaskContext, type MockTaskContextOptions } from "@trigger.dev/core/v3/test";
import { __setSessionOpenImplForTests, __setSessionStartImplForTests } from "../sessions.js";
import {
__setReadChatSnapshotImplForTests,
__setReplaySessionInTailImplForTests,
__setReplaySessionOutTailImplForTests,
__setWriteChatSnapshotImplForTests,
type ChatSnapshotV1,
} from "../ai.js";
import { createTestSessionHandle, type TestSessionOutState } from "./test-session-handle.js";
/** Pre-seed locals before the agent's `run()` starts. */
export type SetupLocals = (locals: {
set<T>(key: LocalsKey<T>, value: T): void;
}) => void | Promise<void>;
// The slim wire payload shape used by chat.agent tasks. Kept loose here so we
// don't import from the backend-only ai.ts module. At most ONE message per
// record — runtime rebuilds prior history from snapshot + replay at boot.
type ChatWirePayload = {
/** At most one message — singular under the slim wire. Set on submit-message. */
message?: UIMessage;
/** Bespoke escape hatch — only set on `trigger: "handover-prepare"`. */
headStartMessages?: UIMessage[];
chatId: string;
trigger:
| "submit-message"
| "regenerate-message"
| "preload"
| "close"
| "action"
| "handover-prepare";
messageId?: string;
metadata?: unknown;
action?: unknown;
continuation?: boolean;
previousRunId?: string;
idleTimeoutInSeconds?: number;
sessionId?: string;
};
/** A reference to a `chat.agent` task returned by `chat.agent({ id, ... })`. */
type ChatAgentHandle = { id: string };
/**
* Options for `mockChatAgent`.
*/
export type MockChatAgentOptions = {
/** The chat session id passed into every wire payload. Defaults to `"test-chat"`. */
chatId?: string;
/** Client-provided metadata (`clientData`) for the session. */
clientData?: unknown;
/** Task context overrides passed through to {@link runInMockTaskContext}. */
taskContext?: MockTaskContextOptions;
/**
* Whether to start the task in preload mode. Defaults to `true` so the
* first `sendMessage()` triggers the first turn via the preload path.
* Set to `false` to skip preload — the first `sendMessage()` starts turn 0 directly.
*
* Ignored when `mode: "handover-prepare"` is set.
*/
preload?: boolean;
/**
* Initial trigger the agent boots with. Defaults to `"preload"` (or
* `"submit-message"` when `preload: false`, or `"continuation"` when
* `continuation: true`).
*
* - `"preload"` — fresh chat preloaded via `transport.preload`. Fires
* `onPreload`, waits for the first message.
* - `"submit-message"` — fresh chat with the first message in the boot
* payload (the `chat.createStartSessionAction({ basePayload: { message } })`
* pattern). Goes straight to turn 0.
* - `"continuation"` — new run picking up an existing session after the
* prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`).
* Boots with `trigger` omitted and `continuation: true` — mirrors what
* the server's `ensureRunForSession` / `swapSessionRun` produces in
* production. The SDK enters its continuation-wait branch; `onPreload`
* and `onChatStart` do NOT fire on this run.
* - `"handover-prepare"` — drives the chat.handover wait branch; call
* `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal.
*/
mode?: "preload" | "submit-message" | "handover-prepare" | "continuation";
/**
* First-turn UIMessage history shipped on the BOOT payload. Only
* meaningful with `mode: "handover-prepare"` — mirrors the
* `chat.headStart` route handler's `basePayload.headStartMessages`.
*/
headStartMessages?: UIMessage[];
/**
* Pre-seed the snapshot the agent reads at run boot. The runtime's
* snapshot read is replaced with one that returns this snapshot
* (skipping the real S3 GET). Use to drive boot scenarios — fresh
* boot with prior history, OOM-retry boot with stale snapshot, etc.
* Pass `undefined` (the default) to start with no snapshot.
*
* See plan section B.3 for the boot orchestration spec.
*/
snapshot?: ChatSnapshotV1;
/**
* Set `payload.continuation = true` on the initial wire payload. Used
* to simulate a continuation-run boot (a new run picking up after a
* prior run on the same session ended via `chat.endRun`, waitpoint
* timeout, or `chat.requestUpgrade`).
*
* Setting this without specifying `mode` auto-selects `mode:
* "continuation"` — the SDK boot path enters its continuation-wait
* branch and waits silently on `session.in` for the first user
* message. `onPreload` and `onChatStart` do NOT fire on this run.
*
* Defaults to `false` (fresh run).
*/
continuation?: boolean;
/**
* Set `payload.previousRunId` on the initial wire payload. Forwarded
* to `onChatStart` / `onTurnStart` and used by the boot gate as a
* prior-state signal. Usually paired with `continuation: true`.
*/
previousRunId?: string;
/**
* Callback that runs **before** the agent's `run()` is invoked, with a
* `set` function for pre-seeding locals. Use this to inject server-side
* dependencies (database clients, service stubs) that the agent reads
* via `locals.get()` in its hooks.
*
* @example
* ```ts
* import { dbKey } from "./db";
*
* const harness = mockChatAgent(agent, {
* chatId: "test-1",
* setupLocals: (locals) => {
* locals.set(dbKey, testDb);
* },
* });
* ```
*/
setupLocals?: SetupLocals;
};
/**
* Result of a single turn, returned by driver methods like `sendMessage()`.
*/
export type MockChatAgentTurn = {
/** UIMessageChunks emitted during this turn (excludes control chunks like turn-complete). */
chunks: UIMessageChunk[];
/** All raw chunks including control chunks (turn-complete, upgrade-required, etc.). */
rawChunks: unknown[];
};
/**
* Harness returned by `mockChatAgent`. Drives a `chat.agent` task end-to-end
* without network or task runtime.
*/
export type MockChatAgentHarness = {
/** The chat session id used by this harness. */
readonly chatId: string;
/**
* Send a single user message (or tool-approval-responded assistant
* message) and wait for the next turn-complete. Returns the chunks
* produced during this turn.
*
* Slim wire: at most ONE message per send. The agent reconstructs prior
* history from snapshot + session.out replay at run boot.
*/
sendMessage(message: UIMessage): Promise<MockChatAgentTurn>;
/**
* Send a regenerate signal (no message body — slim wire). The agent
* trims trailing assistant messages from its in-memory accumulator and
* re-runs. Waits for turn-complete.
*/
sendRegenerate(): Promise<MockChatAgentTurn>;
/**
* Drive the head-start path: sends `trigger: "handover-prepare"` with
* `headStartMessages` carrying the first-turn UIMessage history. Used
* only at the very first turn before any snapshot exists. The route
* handler ships full UIMessage history through this path because the
* customer's HTTP endpoint isn't subject to the `/in/append` cap.
*/
sendHeadStart(args: { messages: UIMessage[] }): Promise<MockChatAgentTurn>;
/** Send a custom action and wait for the next turn-complete. */
sendAction(action: unknown): Promise<MockChatAgentTurn>;
/** Fire a stop signal. Does not wait for the turn — the task keeps running. */
sendStop(message?: string): Promise<void>;
/**
* Dispatch a `handover` signal — the agent picks up partial assistant
* messages and continues the turn. Only meaningful when the harness
* was started with `mode: "handover-prepare"`. Waits for turn-complete.
*
* `isFinal: false` (default) — agent runs `streamText` which executes
* any pending tool-calls (via the approval round) and resumes from
* step 2.
*
* `isFinal: true` — agent runs lifecycle hooks but skips `streamText`.
* The partial IS the response; `onTurnComplete` fires with it.
*/
sendHandover(args: {
partialAssistantMessage: unknown[];
isFinal?: boolean;
messageId?: string;
}): Promise<MockChatAgentTurn>;
/**
* Dispatch a `handover-skip` signal — the agent exits cleanly without
* firing turn hooks. Only meaningful when the harness was started
* with `mode: "handover-prepare"`. Awaits the run finishing.
*/
sendHandoverSkip(): Promise<void>;
/**
* Pre-seed the snapshot read for the next boot. The runtime's snapshot
* read returns this snapshot (skipping S3). Pass `undefined` to clear —
* the boot then sees no snapshot and falls through to replay-only.
*
* Effective on the next run boot only. Calling mid-turn is a no-op
* because the snapshot read happens once at run boot.
*/
seedSnapshot(snapshot: ChatSnapshotV1 | undefined): void;
/**
* Pre-seed `session.out` chunks for the next boot's replay. The runtime's
* `replaySessionOutTail` returns whatever the synthetic chunks reduce
* to. Pass `[]` to clear (boot replay returns no messages).
*
* Requires `__setReplaySessionOutTailImplForTests` exported from
* `ai.ts`. The harness throws a clear error at call time if that hook
* isn't available.
*/
seedSessionOutTail(chunks?: UIMessageChunk[]): void;
/**
* Pre-seed a trailing partial assistant message for the next boot's
* replay. The runtime's `replaySessionOutTail` returns this as the
* `partial` field (alongside whatever `seedSessionOutTail` reduces
* to). Use to simulate cancel-mid-stream: an assistant message whose
* `finish` chunk never arrived. Pass `undefined` to clear.
*
* Effective on the next run boot only.
*/
seedSessionOutPartial(partial: UIMessage | undefined): void;
/**
* Pre-seed user messages on the `session.in` tail for the next boot's
* replay. Each message is paired with a synthetic seq_num (`i + 1`).
* Used to simulate in-flight users the dead predecessor was supposed
* to process. Pass `[]` to clear.
*
* Effective on the next run boot only.
*/
seedSessionInTail(messages: UIMessage[]): void;
/**
* The most recently written snapshot, or `undefined` if no snapshot
* has been written yet. Updated each time `writeChatSnapshot` is
* invoked from the run loop's snapshot-write site (plan section B.6).
*/
getSnapshot(): ChatSnapshotV1 | undefined;
/**
* Close the chat session cleanly. Sends `trigger: "close"` and awaits the
* task's `run()` function returning. Call this at the end of every test
* (or use `await using`) so the background task isn't left dangling.
*/
close(): Promise<void>;
/** All UIMessageChunks emitted since the harness was created. */
readonly allChunks: UIMessageChunk[];
/** Every raw chunk (including control chunks) emitted since the harness was created. */
readonly allRawChunks: unknown[];
};
const CONTROL_CHUNK_TYPES = new Set(["trigger:turn-complete", "trigger:upgrade-required"]);
function isControlChunk(chunk: unknown): boolean {
if (typeof chunk !== "object" || chunk === null) return false;
const type = (chunk as { type?: string }).type;
return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type);
}
/**
* Create an offline test harness for a `chat.agent` task.
*
* The harness starts the agent's `run()` function in a mocked task context,
* waits in preload for the first message, then exposes driver methods for
* sending messages / actions / stop signals and awaiting turn completion.
*
* Users are responsible for mocking the language model themselves — use
* `MockLanguageModelV3` and `simulateReadableStream` from `ai/test` inside
* their agent's `run()` function (typically via DI through `clientData`).
*
* @example
* ```ts
* import { mockChatAgent } from "@trigger.dev/sdk/ai/test";
* import { MockLanguageModelV3, simulateReadableStream } from "ai/test";
* import { myAgent } from "./my-agent";
*
* test("says hello", async () => {
* const harness = mockChatAgent(myAgent, { chatId: "test-1" });
* try {
* const turn = await harness.sendMessage({
* id: "m1",
* role: "user",
* parts: [{ type: "text", text: "hi" }],
* });
* expect(turn.chunks).toContainEqual(
* expect.objectContaining({ type: "text-delta", delta: "hello" })
* );
* } finally {
* await harness.close();
* }
* });
* ```
*/
export function mockChatAgent(
agent: ChatAgentHandle,
options: MockChatAgentOptions = {}
): MockChatAgentHarness {
const chatId = options.chatId ?? "test-chat";
// The agent opens the session with `payload.sessionId ?? payload.chatId`.
// We pass no sessionId, so it falls back to chatId.
const sessionId = chatId;
// `continuation: true` without an explicit mode auto-selects "continuation"
// — the canonical shape for a continuation-run boot.
const mode: "preload" | "submit-message" | "handover-prepare" | "continuation" =
options.mode ??
(options.continuation === true
? "continuation"
: options.preload === false
? "submit-message"
: "preload");
const clientData = options.clientData;
const taskEntry = resourceCatalog.getTask(agent.id);
if (!taskEntry) {
throw new Error(
`mockChatAgent: no task registered with id "${agent.id}". ` +
`Import "@trigger.dev/sdk/ai/test" before your agent module so tasks register correctly.`
);
}
const runFn = taskEntry.fns.run;
// Session .out state: chunks + listener registry. Shared between the
// harness and the TestSessionOutputChannel installed via the open-override.
const sessionOutState: TestSessionOutState = {
chunks: [],
listeners: new Set(),
};
// Buffers that survive across harness method calls
const allRawChunks: unknown[] = [];
const allChunks: UIMessageChunk[] = [];
// Promise that resolves when the background task run() function returns.
let taskFinished!: Promise<void>;
let sendSessionInput!: (sessionId: string, data: unknown) => Promise<void>;
let closeSessionInput: ((sessionId: string) => void) | undefined;
let runSignal!: AbortController;
// A latch that resolves every time `trigger:turn-complete` appears on the chat stream.
// We use a shared pending promise and replace it after each completion.
let turnCompleteResolvers: Array<() => void> = [];
const waitForTurnComplete = () =>
new Promise<void>((resolve) => {
turnCompleteResolvers.push(resolve);
});
// Signal that the caller is ready to observe output
let harnessReadyResolve!: () => void;
const harnessReady = new Promise<void>((resolve) => {
harnessReadyResolve = resolve;
});
// ── Snapshot read/write override state ───────────────────────────────
// The runtime's snapshot read returns whatever `seededSnapshot` is at
// boot time. The runtime's snapshot write captures into
// `lastWrittenSnapshot` for harness consumers to assert via
// `getSnapshot()`. Installed below alongside the session overrides;
// cleared on close in the same finally block.
let seededSnapshot: ChatSnapshotV1 | undefined = options.snapshot;
let lastWrittenSnapshot: ChatSnapshotV1 | undefined;
let seededReplayChunks: UIMessageChunk[] = [];
let seededReplayPartial: UIMessage | undefined;
let seededSessionInMessages: UIMessage[] = [];
__setReadChatSnapshotImplForTests(<T extends UIMessage>(_id: string) => {
return seededSnapshot as ChatSnapshotV1<T> | undefined;
});
__setWriteChatSnapshotImplForTests(
<T extends UIMessage>(_id: string, snapshot: ChatSnapshotV1<T>) => {
lastWrittenSnapshot = snapshot as ChatSnapshotV1;
}
);
// Replay override: install a default that returns whatever
// `seededReplayChunks` reduces to. `mockChatAgent` doesn't model the
// settled-vs-partial split — seeded chunks always reduce to the
// `settled` array with `partial: undefined`. Recovery-specific
// tests can install their own override to seed a partial.
// Cleared in the same `finally` block as the other test overrides.
__setReplaySessionOutTailImplForTests(async () => {
const settled =
seededReplayChunks.length === 0
? []
: ((await reduceChunksToMessages(seededReplayChunks)) as unknown[]);
// For the mock harness, `partialRaw` is the same as `partial` — we
// don't model cleanupAbortedParts separately. Recovery tests that
// need a partialRaw distinct from partial install their own stub.
return {
settled,
partial: seededReplayPartial,
partialRaw: seededReplayPartial,
} as never;
});
// session.in tail override: each seeded UIMessage becomes a
// { message, metadata: undefined, seqNum: i+1 } entry. Mirrors the
// seq-num pattern from the out-tail stub so cursor-advance logic is
// exercised correctly. `metadata` is `undefined` for seeded users —
// the boot path falls back to `payload.metadata` for those.
__setReplaySessionInTailImplForTests(async () => {
return seededSessionInMessages.map((message, i) => ({
message,
metadata: undefined,
seqNum: i + 1,
})) as never;
});
// Install the session open override so `sessions.open(id)` returns a
// SessionHandle with an in-memory `.out` that captures writes. The
// `.in` channel routes record subscriptions (`on`/`once`/`peek`)
// through the `sessionStreams` global — the mock task context
// installs a `TestSessionStreamManager` there — and stubs `wait()`
// so the suspend path resolves cleanly on `runSignal.abort()` without
// touching the api client.
__setSessionOpenImplForTests((id) =>
createTestSessionHandle(id, sessionOutState, () => runSignal?.signal)
);
// Install the session start override so any test path that invokes
// `sessions.start()` (typically through a server action shim like
// `chat.createStartSessionAction`) becomes a no-op fixture instead of
// hitting a real API. Most chat.agent tests trigger the run directly
// via `sendPayloadAndWait` and never go through this path, but the
// stub keeps the API safe to call from inside tested code.
__setSessionStartImplForTests((body) => {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] sessions.start override:", body);
}
const fakeRunId = `run_test_${body.externalId ?? "anon"}`;
return {
id: `session_test_${body.externalId ?? "anon"}`,
externalId: body.externalId ?? null,
type: body.type,
taskIdentifier: body.taskIdentifier,
triggerConfig: body.triggerConfig,
currentRunId: fakeRunId,
runId: fakeRunId,
publicAccessToken: "tr_test_session_pat",
tags: body.tags ?? [],
metadata: (body.metadata ?? null) as Record<string, unknown> | null,
closedAt: null,
closedReason: null,
expiresAt: null,
createdAt: new Date(0),
updatedAt: new Date(0),
isCached: false,
};
});
taskFinished = runInMockTaskContext(async (drivers) => {
runSignal = new AbortController();
// For `mode: "continuation"`, omit `trigger` from the wire payload —
// mirrors what the server's `ensureRunForSession` / `swapSessionRun`
// produces (the continuation overrides clear `trigger` so the SDK
// boot path falls into the continuation-wait branch instead of
// re-firing the basePayload's stale first-run trigger). `continuation:
// true` is set unconditionally for this mode so the boot path's
// continuation-wait condition matches.
const isContinuationMode = mode === "continuation";
const initialPayload: ChatWirePayload = {
chatId,
...(isContinuationMode
? { trigger: undefined as never, continuation: true }
: { trigger: mode }),
metadata: clientData,
...(!isContinuationMode && options.continuation ? { continuation: true } : {}),
...(options.previousRunId ? { previousRunId: options.previousRunId } : {}),
...(options.headStartMessages ? { headStartMessages: options.headStartMessages } : {}),
};
sendSessionInput = drivers.sessions.in.send;
closeSessionInput = drivers.sessions.in.close;
// Record every chunk written to session.out, detect turn-complete.
const listener = (chunk: unknown) => {
allRawChunks.push(chunk);
if (!isControlChunk(chunk)) {
allChunks.push(chunk as UIMessageChunk);
}
if (
typeof chunk === "object" &&
chunk !== null &&
(chunk as { type?: string }).type === "trigger:turn-complete"
) {
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
}
};
sessionOutState.listeners.add(listener);
const unsubscribe = () => sessionOutState.listeners.delete(listener);
if (options.setupLocals) {
await options.setupLocals({ set: drivers.locals.set });
}
harnessReadyResolve();
try {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] Starting runFn with payload:", initialPayload);
}
await runFn(initialPayload, {
ctx: drivers.ctx,
signal: runSignal.signal,
});
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] runFn returned");
}
} catch (err) {
if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") {
console.log("[mockChatAgent] runFn threw:", err);
}
throw err;
} finally {
unsubscribe();
// Resolve any outstanding turn-complete waiters so callers don't hang
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
}
}, options.taskContext)
.catch((err) => {
// Propagate errors to pending turn waiters instead of dropping them
const resolvers = turnCompleteResolvers;
turnCompleteResolvers = [];
for (const resolve of resolvers) resolve();
throw err;
})
.finally(() => {
// Always clear the test overrides, even if the task threw.
__setSessionOpenImplForTests(undefined);
__setSessionStartImplForTests(undefined);
__setReadChatSnapshotImplForTests(undefined);
__setWriteChatSnapshotImplForTests(undefined);
__setReplaySessionOutTailImplForTests(undefined);
__setReplaySessionInTailImplForTests(undefined);
});
const sendPayloadAndWait = async (payload: ChatWirePayload): Promise<MockChatAgentTurn> => {
await harnessReady;
const before = allRawChunks.length;
const turnComplete = waitForTurnComplete();
await sendSessionInput(sessionId, { kind: "message", payload });
await turnComplete;
const rawChunks = allRawChunks.slice(before);
const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[];
return { chunks, rawChunks };
};
const harness: MockChatAgentHarness = {
chatId,
async sendMessage(message) {
return sendPayloadAndWait({
message,
chatId,
trigger: "submit-message",
metadata: clientData,
});
},
async sendRegenerate() {
return sendPayloadAndWait({
chatId,
trigger: "regenerate-message",
metadata: clientData,
});
},
async sendHeadStart({ messages }) {
return sendPayloadAndWait({
headStartMessages: messages,
chatId,
trigger: "handover-prepare",
metadata: clientData,
});
},
async sendAction(action) {
return sendPayloadAndWait({
chatId,
trigger: "action",
action,
metadata: clientData,
});
},
async sendStop(message) {
await harnessReady;
await sendSessionInput(sessionId, { kind: "stop", message });
},
async sendHandover(args) {
await harnessReady;
const before = allRawChunks.length;
const turnComplete = waitForTurnComplete();
await sendSessionInput(sessionId, {
kind: "handover",
partialAssistantMessage: args.partialAssistantMessage,
messageId: args.messageId,
isFinal: args.isFinal ?? false,
});
await turnComplete;
const rawChunks = allRawChunks.slice(before);
const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[];
return { chunks, rawChunks };
},
async sendHandoverSkip() {
await harnessReady;
// No turn-complete on skip — the agent exits without firing hooks.
// Send the chunk and wait for the run to finish.
await sendSessionInput(sessionId, { kind: "handover-skip" });
await Promise.race([
taskFinished.catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
]);
},
seedSnapshot(snapshot) {
seededSnapshot = snapshot;
},
seedSessionOutTail(chunks) {
seededReplayChunks = chunks ?? [];
},
seedSessionOutPartial(partial) {
seededReplayPartial = partial;
},
seedSessionInTail(messages) {
seededSessionInMessages = messages;
},
getSnapshot() {
return lastWrittenSnapshot;
},
async close() {
await harnessReady;
// Send a close trigger wrapped as a `kind: "message"` ChatInputChunk.
// The turn loop checks for this after a successful turn and exits
// cleanly. On error-recovery paths the loop just loops back with
// the close payload, so we also close the session input below to
// unblock any pending once() waiters.
try {
await sendSessionInput(sessionId, {
kind: "message",
payload: {
chatId,
trigger: "close",
},
});
} catch {
// best-effort
}
// Resolve any pending once() waiters on the session input with a
// timeout error — that makes waitWithIdleTimeout return
// `{ ok: false }` and the turn loop exits cleanly.
closeSessionInput?.(sessionId);
// Also abort the run signal so anything downstream (streamText,
// deferred work) unwinds promptly.
runSignal?.abort("close");
// Wait for run() to return. The loop's error recovery path will
// see !next.ok and exit. Use a bounded wait so tests never hang.
await Promise.race([
taskFinished.catch(() => {}),
new Promise<void>((resolve) => setTimeout(resolve, 1000)),
]);
},
get allChunks() {
return allChunks.slice();
},
get allRawChunks() {
return allRawChunks.slice();
},
};
return harness;
}
/**
* Reduce a synthetic UIMessageChunk[] sequence into the UIMessage[] that
* the runtime's `replaySessionOutTail` would produce. Splits chunks at
* `start` boundaries and feeds each segment through AI SDK's
* `readUIMessageStream`. The trailing un-finished segment goes through
* `cleanupAbortedParts`. Mirrors the production reducer used in
* `ai.ts:replaySessionOutTail`.
*/
async function reduceChunksToMessages(chunks: UIMessageChunk[]): Promise<UIMessage[]> {
if (chunks.length === 0) return [];
const aiModule = (await import("ai")) as {
readUIMessageStream?: (args: {
stream: ReadableStream<UIMessageChunk>;
}) => AsyncIterable<UIMessage>;
cleanupAbortedParts?: (msg: UIMessage) => UIMessage;
};
const readUIMessageStream = aiModule.readUIMessageStream;
const cleanupAbortedParts = aiModule.cleanupAbortedParts;
if (!readUIMessageStream) return [];
type Segment = { chunks: UIMessageChunk[]; closed: boolean };
const segments: Segment[] = [];
let current: Segment | undefined;
for (const chunk of chunks) {
if (chunk.type === "start") {
current = { chunks: [chunk], closed: false };
segments.push(current);
continue;
}
if (!current) {
current = { chunks: [], closed: false };
segments.push(current);
}
current.chunks.push(chunk);
if (chunk.type === "finish") {
current.closed = true;
current = undefined;
}
}
const out: UIMessage[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]!;
const isTrailing = i === segments.length - 1 && !seg.closed;
const segmentStream = new ReadableStream<UIMessageChunk>({
start(controller) {
for (const c of seg.chunks) controller.enqueue(c);
controller.close();
},
});
let last: UIMessage | undefined;
try {
for await (const snapshot of readUIMessageStream({ stream: segmentStream })) {
last = snapshot;
}
} catch {
// Skip malformed segment — tests can assert by inspecting what makes it through.
continue;
}
if (!last) continue;
if (isTrailing && cleanupAbortedParts) {
const cleaned = cleanupAbortedParts(last);
if (!cleaned.parts || cleaned.parts.length === 0) continue;
out.push(cleaned);
} else {
out.push(last);
}
}
return out;
}