790 lines
29 KiB
TypeScript
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;
|
|
}
|