chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -0,0 +1,280 @@
// Import the test entry point first so the resource catalog is installed —
// not strictly required for these helper-level tests, but keeps parity with
// the rest of the test suite and removes a potential foot-gun if a future
// edit introduces a chat.agent({...}) at module scope.
import "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__readChatSnapshotProductionPathForTests as readChatSnapshot,
__writeChatSnapshotProductionPathForTests as writeChatSnapshot,
type ChatSnapshotV1,
} from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Build a minimal ChatSnapshotV1 with `count` user messages. Used as the
* production-path test payload — `messages` is the only field the runtime
* inspects beyond `version`.
*/
function buildSnapshot(count = 1): ChatSnapshotV1 {
return {
version: 1,
savedAt: 1_000_000,
messages: Array.from({ length: count }, (_, i) => ({
id: `m${i}`,
role: "user" as const,
parts: [{ type: "text" as const, text: `hello ${i}` }],
})),
lastOutEventId: "evt-42",
};
}
/**
* Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API
* client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve
* with the presigned URLs the test wants. Returns spies for assertion.
*/
function stubApiClient(opts: {
getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>;
}) {
const getChatSnapshotUrl = vi.fn(
opts.getChatSnapshotUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" }))
);
const createChatSnapshotUploadUrl = vi.fn(
opts.createChatSnapshotUploadUrl ??
(async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" }))
);
const fakeClient = {
getChatSnapshotUrl,
createChatSnapshotUploadUrl,
};
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never);
return { getChatSnapshotUrl, createChatSnapshotUploadUrl };
}
/**
* Stub global `fetch` so the helpers see whatever Response (or throw) the
* test wants. Returns a spy keyed on the URL passed.
*/
function stubFetch(impl: (url: string, init?: RequestInit) => Promise<Response> | Response) {
const spy = vi.fn(impl);
vi.stubGlobal("fetch", spy);
return spy;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat snapshot helpers", () => {
// Suppress the runtime's `logger.warn` calls — they pollute output but
// don't change test outcomes. Restored in afterEach.
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
warnSpy.mockRestore();
});
describe("readChatSnapshot", () => {
it("returns the snapshot on a successful GET", async () => {
const { getChatSnapshotUrl } = stubApiClient({});
const snapshot = buildSnapshot(2);
stubFetch(
async () =>
new Response(JSON.stringify(snapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("session-1");
expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1");
expect(result).toMatchObject({
version: 1,
messages: snapshot.messages,
lastOutEventId: "evt-42",
});
});
it("returns undefined on 404 (fresh session, no snapshot yet)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Not Found", { status: 404 }));
const result = await readChatSnapshot("missing-session");
expect(result).toBeUndefined();
});
it("returns undefined on non-404 non-OK (e.g. 500)", async () => {
stubApiClient({});
stubFetch(async () => new Response("Internal Error", { status: 500 }));
const result = await readChatSnapshot("flaky-session");
expect(result).toBeUndefined();
});
it("returns undefined when the response body is malformed JSON", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response("not-json-{[", {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("malformed-session");
expect(result).toBeUndefined();
});
it("returns undefined on version mismatch (forward-compat)", async () => {
stubApiClient({});
// Future format the current runtime can't decode — runtime ignores it.
const futureSnapshot = {
version: 99,
savedAt: Date.now(),
messages: [],
};
stubFetch(
async () =>
new Response(JSON.stringify(futureSnapshot), {
status: 200,
headers: { "content-type": "application/json" },
})
);
const result = await readChatSnapshot("v99-session");
expect(result).toBeUndefined();
});
it("returns undefined when `messages` field is missing or wrong type", async () => {
stubApiClient({});
stubFetch(
async () =>
new Response(JSON.stringify({ version: 1, savedAt: 1, messages: "not-an-array" }), {
status: 200,
})
);
const result = await readChatSnapshot("bad-shape-session");
expect(result).toBeUndefined();
});
it("returns undefined when fetch throws (network error)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ECONNREFUSED");
});
const result = await readChatSnapshot("offline-session");
expect(result).toBeUndefined();
});
it("returns undefined when presign call fails", async () => {
stubApiClient({
getChatSnapshotUrl: async () => {
throw new Error("presign denied");
},
});
// No fetch should fire — presign failed.
const fetchSpy = stubFetch(async () => new Response("nope", { status: 500 }));
const result = await readChatSnapshot("denied-session");
expect(result).toBeUndefined();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("returns undefined when the response is not an object", async () => {
stubApiClient({});
stubFetch(async () => new Response(JSON.stringify("just-a-string"), { status: 200 }));
const result = await readChatSnapshot("string-response");
expect(result).toBeUndefined();
});
});
describe("writeChatSnapshot", () => {
it("PUTs the snapshot JSON to the presigned URL", async () => {
const { createChatSnapshotUploadUrl } = stubApiClient({});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
const snapshot = buildSnapshot(3);
await writeChatSnapshot("session-2", snapshot);
expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2");
expect(fetchSpy).toHaveBeenCalledOnce();
const [url, init] = fetchSpy.mock.calls[0]!;
expect(url).toBe("https://example.invalid/put");
expect((init as RequestInit).method).toBe("PUT");
expect((init as RequestInit).headers).toMatchObject({
"content-type": "application/json",
});
// Body is the JSON-stringified snapshot — round-trip to confirm.
const sentBody = JSON.parse((init as RequestInit).body as string);
expect(sentBody).toEqual(snapshot);
});
it("returns without throwing on a non-OK PUT response (warns)", async () => {
stubApiClient({});
stubFetch(async () => new Response("forbidden", { status: 403 }));
await expect(
writeChatSnapshot("forbidden-session", buildSnapshot())
).resolves.toBeUndefined();
});
it("returns without throwing on a fetch network error (warns)", async () => {
stubApiClient({});
stubFetch(async () => {
throw new Error("ETIMEDOUT");
});
await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined();
});
it("returns without throwing when presign fails (warns)", async () => {
stubApiClient({
createChatSnapshotUploadUrl: async () => {
throw new Error("presign denied");
},
});
const fetchSpy = stubFetch(async () => new Response(null, { status: 200 }));
await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined();
// Presign failed → no PUT attempted.
expect(fetchSpy).not.toHaveBeenCalled();
});
it("addresses reads and writes by the same sessionId", async () => {
// Round-trip check: both presign methods receive the same sessionId.
// The canonical key (`sessions/{id}/snapshot.json`) lives server-side
// now, so the SDK has no key string to compare — sessionId equality
// is the SDK-visible invariant.
const { getChatSnapshotUrl } = stubApiClient({
getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }),
});
stubFetch(async () => new Response(null, { status: 404 }));
await readChatSnapshot("round-trip-session");
const [readArg] = getChatSnapshotUrl.mock.calls[0]!;
const { createChatSnapshotUploadUrl } = stubApiClient({
createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }),
});
stubFetch(async () => new Response(null, { status: 200 }));
await writeChatSnapshot("round-trip-session", buildSnapshot());
const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!;
expect(readArg).toBe(writeArg);
expect(readArg).toBe("round-trip-session");
});
});
});
@@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "ai";
import {
TriggerChatTransport,
type ChatTransportEvent,
type TriggerChatTransportOptions,
} from "../src/v3/chat.js";
// ── Helpers ────────────────────────────────────────────────────────────
function user(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function jsonOk(): Response {
return new Response("{}", { status: 200 });
}
/** Build a `text/event-stream` Response from raw SSE text (v1 wire). */
function sseResponse(frames: string): Response {
return new Response(frames, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
/** SSE body: one data chunk then a legacy turn-complete control chunk. */
const SSE_ONE_TURN = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"hello"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
async function readAll(stream: ReadableStream<unknown>): Promise<unknown[]> {
const out: unknown[] = [];
const reader = stream.getReader();
while (true) {
const next = await reader.read();
if (next.done) return out;
out.push(next.value);
}
}
function makeTransport(overrides: Partial<TriggerChatTransportOptions> = {}) {
const events: ChatTransportEvent[] = [];
const transport = new TriggerChatTransport({
task: "test-task",
accessToken: async () => "tok_test",
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
onEvent: (event) => events.push(event),
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(SSE_ONE_TURN),
...overrides,
});
return { transport, events };
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("transport send events", () => {
it("emits message-sent for a successful submit and the full stream lifecycle", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
const sent = events[0] as Extract<ChatTransportEvent, { type: "message-sent" }>;
expect(sent.chatId).toBe("c1");
expect(sent.messageId).toBe("u-1");
expect(sent.source).toBe("submit-message");
expect(sent.durationMs).toBeGreaterThanOrEqual(0);
expect(sent.timestamp).toBeGreaterThan(0);
expect(sent.partId).toMatch(/[0-9a-f-]{36}/);
expect(sent.bodyBytes).toBeGreaterThan(0);
const connected = events[1] as Extract<ChatTransportEvent, { type: "stream-connected" }>;
expect(connected.resumed).toBe(false);
expect(connected.messageId).toBe("u-1");
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("text-delta");
expect(firstChunk.lastEventId).toBe("1");
expect(firstChunk.messageId).toBe("u-1");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
const turnCompleted = events[3] as Extract<ChatTransportEvent, { type: "turn-completed" }>;
expect(turnCompleted.messageId).toBe("u-1");
expect(turnCompleted.sinceSendMs).toBeGreaterThanOrEqual(0);
expect(turnCompleted.lastEventId).toBe("2");
});
it("emits message-send-failed with the HTTP status when the append fails", async () => {
const { transport, events } = makeTransport({
fetch: async () => new Response("too large", { status: 413 }),
});
await expect(
transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
})
).rejects.toThrow();
expect(events).toHaveLength(1);
const failed = events[0] as Extract<ChatTransportEvent, { type: "message-send-failed" }>;
expect(failed.type).toBe("message-send-failed");
expect(failed.source).toBe("submit-message");
expect(failed.messageId).toBe("u-1");
expect(failed.status).toBe(413);
expect(failed.error).toBeInstanceOf(Error);
});
it("emits steer events from sendPendingMessage without changing its boolean result", async () => {
const { transport, events } = makeTransport();
const ok = await transport.sendPendingMessage("c1", user("steer", "u-2"));
expect(ok).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "steer", messageId: "u-2" });
const failing = makeTransport({ fetch: async () => new Response("nope", { status: 500 }) });
const notOk = await failing.transport.sendPendingMessage("c1", user("steer", "u-3"));
expect(notOk).toBe(false);
expect(failing.events[0]).toMatchObject({
type: "message-send-failed",
source: "steer",
status: 500,
});
});
it("emits action and stop send events", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendAction("c1", { type: "undo" });
await readAll(stream);
expect(events[0]).toMatchObject({ type: "message-sent", source: "action" });
events.length = 0;
const stopped = await transport.stopGeneration("c1");
expect(stopped).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "stop" });
});
it("swallows exceptions thrown by the onEvent callback", async () => {
const { transport } = makeTransport({
onEvent: () => {
throw new Error("observer exploded");
},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
const chunks = await readAll(stream);
expect(chunks.length).toBeGreaterThan(0);
});
});
describe("transport stream events", () => {
it("marks reconnectToStream subscriptions as resumed", async () => {
const { transport, events } = makeTransport({
sessions: {
c1: { publicAccessToken: "tok_test", isStreaming: true, lastEventId: "1" },
},
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
expect(stream).not.toBeNull();
await readAll(stream!);
const connected = events.find((e) => e.type === "stream-connected") as Extract<
ChatTransportEvent,
{ type: "stream-connected" }
>;
expect(connected.resumed).toBe(true);
expect(events.some((e) => e.type === "turn-completed")).toBe(true);
});
it("re-arms first-chunk per turn on a watch-mode stream", async () => {
const TWO_TURNS = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"turn one"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
`id: 3`,
`data: {"type":"text-delta","id":"t2","delta":"turn two"}`,
``,
`id: 4`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const { transport, events } = makeTransport({
watch: true,
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } },
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS),
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
await readAll(stream!);
expect(events.filter((e) => e.type === "first-chunk")).toHaveLength(2);
expect(events.filter((e) => e.type === "turn-completed")).toHaveLength(2);
});
it("emits the full lifecycle on the headStart first-turn path", async () => {
const handoverSse = [
`data: {"type":"start","messageId":"a-1"}`,
``,
`data: {"type":"text-delta","id":"t1","delta":"warm"}`,
``,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(handoverSse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({
headStart: "/api/chat",
sessions: {},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
expect(events[0]).toMatchObject({ source: "head-start", messageId: "u-hs" });
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("start");
expect(firstChunk.messageId).toBe("u-hs");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the headStart response body fails mid-read", async () => {
const encoder = new TextEncoder();
const failingBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: {"type":"text-delta","id":"t1","delta":"w"}\n\n`));
controller.error(new Error("network drop"));
},
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(failingBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({ headStart: "/api/chat", sessions: {} });
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs-err",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow("network drop");
const streamError = events.find((e) => e.type === "stream-error");
expect(streamError).toBeDefined();
expect(events.some((e) => e.type === "turn-completed")).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the output stream fails unrecoverably", async () => {
const { transport, events } = makeTransport({
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : new Response("gone", { status: 400 }),
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow();
expect(events.some((e) => e.type === "stream-error")).toBe(true);
expect(events.some((e) => e.type === "stream-connected")).toBe(false);
});
});
@@ -0,0 +1,110 @@
import { describe, expect, it } from "vitest";
import type { UIMessage } from "ai";
import { TriggerChatTransport, type TriggerChatTransportOptions } from "../src/v3/chat.js";
// A send's `.out` stream must close on the turn that consumed its own appended
// record, not an earlier turn-complete (e.g. a racing undo action). The seq
// comes back from `/in/append`; correlation headers ride the v2 batch wire.
function user(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
type BatchRecord = {
body: string;
seq_num: number;
timestamp: number;
headers?: Array<[string, string]>;
};
function batchResponse(records: BatchRecord[]): Response {
const frames = records
.map((r) => `event: batch\ndata: ${JSON.stringify({ records: [r] })}\n\n`)
.join("");
return new Response(frames, {
status: 200,
headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v2" },
});
}
/** A turn-complete control record whose committed `.in` cursor is `inCursor`. */
function turnComplete(seqNum: number, inCursor: number): BatchRecord {
return {
body: "",
seq_num: seqNum,
timestamp: seqNum,
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", String(inCursor)],
],
};
}
function textDelta(seqNum: number, text: string): BatchRecord {
return {
body: JSON.stringify({ data: { type: "text-delta", id: "t1", delta: text }, id: "m1" }),
seq_num: seqNum,
timestamp: seqNum,
headers: [],
};
}
function inResponse(seq?: number): Response {
return new Response(JSON.stringify(seq === undefined ? { ok: true } : { ok: true, seq }), {
status: 200,
});
}
async function readDeltas(stream: ReadableStream<unknown>): Promise<string[]> {
const out: string[] = [];
const reader = stream.getReader();
while (true) {
const next = await reader.read();
if (next.done) return out;
const chunk = next.value as { type?: string; delta?: string };
if (chunk?.type === "text-delta" && typeof chunk.delta === "string") out.push(chunk.delta);
}
}
function makeTransport(out: Response, inSeq: number | undefined) {
const options: TriggerChatTransportOptions = {
task: "test-task",
accessToken: async () => "tok_test",
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
fetch: async (_url, _init, ctx) => (ctx.endpoint === "in" ? inResponse(inSeq) : out),
};
return new TriggerChatTransport(options);
}
async function submit(transport: TriggerChatTransport): Promise<string[]> {
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
return readDeltas(stream);
}
describe("transport turn correlation", () => {
it("skips an earlier turn's turn-complete and closes on its own", async () => {
// Append seq 5; the undo turn's complete (cursor 4) must be skipped.
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
const deltas = await submit(makeTransport(out, 5));
expect(deltas).toEqual(["56"]);
});
it("does not skip when the turn-complete is at the send's own seq", async () => {
const out = batchResponse([textDelta(10, "56"), turnComplete(11, 5)]);
const deltas = await submit(makeTransport(out, 5));
expect(deltas).toEqual(["56"]);
});
it("without an append seq, closes on the first turn-complete (legacy webapp)", async () => {
// No seq => no baseline => old behavior: close on the first turn-complete.
const out = batchResponse([turnComplete(10, 4), textDelta(11, "56"), turnComplete(12, 5)]);
const deltas = await submit(makeTransport(out, undefined));
expect(deltas).toEqual([]);
});
});
@@ -0,0 +1,635 @@
// Import the test harness FIRST — installs the resource catalog so
// `chat.agent()` calls below register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { simulateReadableStream, streamText, tool } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { z } from "zod";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
],
});
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.handover", () => {
it("handover-skip (error path) exits cleanly without firing turn hooks", async () => {
// `handover-skip` is now only sent when the customer's handler
// ABORTS before producing a finishReason (dispatch error). The
// agent run exits clean, no hooks fire. Normal pure-text and
// tool-call finishes go through `kind: "handover"`.
const onChatStart = vi.fn();
const onTurnStart = vi.fn();
const onTurnComplete = vi.fn();
const onPreload = vi.fn();
const runFn = vi.fn();
const agent = chat.agent({
id: "chat.handover.skip",
onPreload,
onChatStart,
onTurnStart,
onTurnComplete,
run: async ({ messages, signal }) => {
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-skip",
mode: "handover-prepare",
});
try {
await harness.sendHandoverSkip();
// Give any deferred work a tick.
await new Promise((r) => setTimeout(r, 20));
// No turn hooks fire on skip — the run boots, waits, and exits.
expect(onPreload).not.toHaveBeenCalled();
expect(onTurnStart).not.toHaveBeenCalled();
expect(onTurnComplete).not.toHaveBeenCalled();
expect(runFn).not.toHaveBeenCalled();
// No content chunks were emitted — only the boot scaffolding (if any).
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
it("pure-text head-start (isFinal: true) runs full hook chain WITHOUT calling streamText", async () => {
// Pure-text first turn: customer's step 1 produced the final
// response. The agent runs onChatStart → onTurnStart →
// onTurnComplete (so persistence works), but SKIPS the user's
// run() callback entirely (no LLM call, no streamText).
// onTurnComplete fires with the customer's partial as
// `responseMessage`.
const order: string[] = [];
const runFn = vi.fn();
let capturedResponse: { id?: string; partTypes?: string[]; firstText?: string } | undefined;
const agent = chat.agent({
id: "chat.handover.pure-text",
onChatStart: () => {
order.push("onChatStart");
},
onTurnStart: () => {
order.push("onTurnStart");
},
onTurnComplete: ({ responseMessage }) => {
order.push("onTurnComplete");
capturedResponse = {
id: responseMessage?.id,
partTypes: (responseMessage?.parts ?? []).map((p) => p.type),
firstText: (responseMessage?.parts ?? [])
.filter((p) => p.type === "text")
.map((p) => (p as { text?: string }).text || "")
.join(""),
};
},
run: async ({ messages, signal }) => {
// Should NOT be called for isFinal: true.
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-final",
mode: "handover-prepare",
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [{ type: "text", text: "Hi there, hope you're well." }],
},
],
messageId: "asst-msg-1",
isFinal: true,
});
// `onTurnComplete` fires AFTER the `trigger:turn-complete` chunk,
// and the harness's `sendHandover` resolves on that chunk —
// give onTurnComplete a tick to run.
await new Promise((r) => setTimeout(r, 30));
// All three hooks fired in order.
expect(order).toEqual(["onChatStart", "onTurnStart", "onTurnComplete"]);
// The user's run() was NEVER invoked — no LLM call from the agent.
expect(runFn).not.toHaveBeenCalled();
// onTurnComplete saw the customer's partial as responseMessage,
// with the matching messageId for browser-side merging.
expect(capturedResponse).toBeDefined();
expect(capturedResponse!.id).toBe("asst-msg-1");
expect(capturedResponse!.partTypes).toContain("text");
expect(capturedResponse!.firstText).toBe("Hi there, hope you're well.");
} finally {
await harness.close();
}
});
it("handover with schema-only pending tool-call resumes via approval-driven execution", async () => {
// Customer-side tools are schema-only (no `execute` fn) — AI SDK
// doesn't execute them, so `result.response.messages` after step 1
// contains JUST the assistant message with the pending tool-call.
// `chat-server.ts` reshapes this into AI SDK's tool-approval round
// (assistant + tool-approval-request, tool with tool-approval-response)
// before sending the handover signal. That's the wire shape this
// test simulates.
//
// The agent ships the same tool — but with the heavy `execute` fn.
// When the next `streamText` runs, AI SDK's initial-tool-execution
// branch (stream-text.ts:1342-1486) sees the approval round, runs
// the agent-side execute, and synthesizes a tool-result before the
// step-2 LLM call.
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({
city,
temp: 22,
}));
const weatherTool = tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: toolExecute,
});
const stepTwoStream = textStream("the weather in tokyo is 22°C");
const agent = chat.agent({
id: "chat.handover.schema-only-tool",
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: stepTwoStream }),
}),
messages,
tools: { weather: weatherTool },
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-schema-only",
mode: "handover-prepare",
});
try {
const turn = await harness.sendHandover({
isFinal: false, // pending tool-call → agent runs streamText
partialAssistantMessage: [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{
type: "tool-call",
toolCallId: "tc-1",
toolName: "weather",
input: { city: "tokyo" },
},
{
type: "tool-approval-request",
approvalId: "handover-approval-1",
toolCallId: "tc-1",
},
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-1",
approved: true,
},
],
},
],
});
// The agent-side execute ran (this is the whole point of the
// schema-only-on-customer pattern).
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
// Step-2 produced text was streamed through session.out.
const text = turn.chunks
.filter((c) => c.type === "text-delta")
.map((c) => (c as { delta: string }).delta)
.join("");
expect(text).toContain("tokyo");
expect(text).toContain("22°C");
} finally {
await harness.close();
}
});
it("pure-text head-start preserves reasoning parts in the response (TRI-10716)", async () => {
// Extended-thinking models stream a reasoning part in step 1. The
// synthesized partial must carry it (with provider metadata, so an
// Anthropic signature survives a UIMessage -> ModelMessage round
// trip) or the durable history loses the step-1 thinking.
let captured: { partTypes?: string[]; reasoningText?: string; meta?: unknown } | undefined;
const agent = chat.agent({
id: "chat.handover.reasoning",
onTurnComplete: ({ responseMessage }) => {
const parts = responseMessage?.parts ?? [];
captured = {
partTypes: parts.map((p) => p.type),
reasoningText: parts
.filter((p) => p.type === "reasoning")
.map((p) => (p as { text?: string }).text || "")
.join(""),
meta: (
parts.find((p) => p.type === "reasoning") as { providerMetadata?: unknown } | undefined
)?.providerMetadata,
};
},
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-reasoning",
mode: "handover-prepare",
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [
{
type: "reasoning",
text: "thinking about the greeting",
providerOptions: { anthropic: { signature: "sig-abc" } },
},
{ type: "text", text: "Hello!" },
],
},
],
messageId: "asst-reason-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 30));
expect(captured).toBeDefined();
expect(captured!.partTypes).toEqual(["reasoning", "text"]);
expect(captured!.reasoningText).toBe("thinking about the greeting");
expect(captured!.meta).toEqual({ anthropic: { signature: "sig-abc" } });
} finally {
await harness.close();
}
});
it("pure-text head-start (isFinal: true) with hydrateMessages persists the partial (TRI-10715)", async () => {
// Same as the pure-text case above, but the customer registers
// `hydrateMessages` (the documented DB-as-source-of-truth pattern).
// The head-start user message must reach the hydrate hook as
// `incomingMessages`, and the warm route's partial must land in the
// accumulator so `onTurnComplete` carries the full first turn.
const runFn = vi.fn();
const stored: { id: string; role: string; parts: unknown[] }[] = [];
const hydrateIncomingRoles: string[] = [];
let captured: { responseId?: string; responseText?: string; roles?: string[] } | undefined;
const agent = chat.agent({
id: "chat.handover.hydrate-pure-text",
hydrateMessages: async ({ incomingMessages }) => {
hydrateIncomingRoles.push(...incomingMessages.map((m) => m.role));
for (const m of incomingMessages) {
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
}
return [...stored] as never;
},
onTurnComplete: ({ responseMessage, uiMessages }) => {
captured = {
responseId: responseMessage?.id,
responseText: (responseMessage?.parts ?? [])
.filter((p) => p.type === "text")
.map((p) => (p as { text?: string }).text || "")
.join(""),
roles: uiMessages.map((m) => m.role),
};
},
run: async ({ messages, signal }) => {
runFn();
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("should-not-run") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-hydrate-final",
mode: "handover-prepare",
headStartMessages: [
{ id: "hs-user-1", role: "user", parts: [{ type: "text", text: "say hi" }] },
],
});
try {
await harness.sendHandover({
partialAssistantMessage: [
{
role: "assistant",
content: [{ type: "text", text: "Hi there, hope you're well." }],
},
],
messageId: "asst-hydrate-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 30));
// isFinal — the agent never calls the user's run().
expect(runFn).not.toHaveBeenCalled();
// The head-start user message reached the hydrate hook as incoming.
expect(hydrateIncomingRoles).toContain("user");
// onTurnComplete carries the full first turn: user + the warm
// route's assistant, under the handover messageId.
expect(captured).toBeDefined();
expect(captured!.roles).toEqual(["user", "assistant"]);
expect(captured!.responseId).toBe("asst-hydrate-1");
expect(captured!.responseText).toBe("Hi there, hope you're well.");
} finally {
await harness.close();
}
});
it("tool-call handover (isFinal: false) with hydrateMessages resumes from step 2 (TRI-10715)", async () => {
// Hydrate variant of the schema-only tool-call case: the spliced
// partial (assistant + approval round) must reach the agent's
// streamText so AI SDK executes the pending tool instead of
// re-running step 1 from scratch against an empty/short prompt.
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const weatherTool = tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: toolExecute,
});
const stored: { id: string; role: string; parts: unknown[] }[] = [];
let runMessageRoles: string[] | undefined;
let captured: { roles?: string[]; assistantIds?: (string | undefined)[] } | undefined;
const agent = chat.agent({
id: "chat.handover.hydrate-schema-only-tool",
hydrateMessages: async ({ incomingMessages }) => {
for (const m of incomingMessages) {
if (!stored.some((s) => s.id === m.id)) stored.push(m as (typeof stored)[number]);
}
return [...stored] as never;
},
onTurnComplete: ({ uiMessages }) => {
captured = {
roles: uiMessages.map((m) => m.role),
assistantIds: uiMessages.filter((m) => m.role === "assistant").map((m) => m.id),
};
},
run: async ({ messages, signal }) => {
runMessageRoles = messages.map((m) => m.role);
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages,
tools: { weather: weatherTool },
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-hydrate-tool",
mode: "handover-prepare",
headStartMessages: [
{ id: "hs-user-2", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] },
],
});
try {
const turn = await harness.sendHandover({
isFinal: false,
messageId: "asst-hydrate-2",
partialAssistantMessage: [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{
type: "tool-call",
toolCallId: "tc-h1",
toolName: "weather",
input: { city: "tokyo" },
},
{
type: "tool-approval-request",
approvalId: "handover-approval-h1",
toolCallId: "tc-h1",
},
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-h1",
approved: true,
},
],
},
],
});
await new Promise((r) => setTimeout(r, 30));
// The resume prompt contained the full splice: user + partial
// assistant + approval round — NOT an empty/user-only prompt.
expect(runMessageRoles).toEqual(["user", "assistant", "tool"]);
// AI SDK's initial-tool-execution branch ran the agent-side
// execute (no step-1 re-run).
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
// Step-2 text streamed through session.out.
const text = turn.chunks
.filter((c) => c.type === "text-delta")
.map((c) => (c as { delta: string }).delta)
.join("");
expect(text).toContain("tokyo");
// One assistant in the final chain, under the handover messageId.
expect(captured).toBeDefined();
expect(captured!.roles).toEqual(["user", "assistant"]);
expect(captured!.assistantIds).toEqual(["asst-hydrate-2"]);
} finally {
await harness.close();
}
});
it("onTurnStart fires after the handover signal arrives (lazy)", async () => {
// Hooks should not fire during the wait — only once handover lands
// and a real turn begins. Verifies the order so customers can
// mutate `chat.history` inside `onTurnStart` knowing the partial
// assistant message is in scope.
const events: string[] = [];
const agent = chat.agent({
id: "chat.handover.lazy-hooks",
onPreload: () => {
events.push("onPreload");
},
onChatStart: () => {
events.push("onChatStart");
},
onTurnStart: () => {
events.push("onTurnStart");
},
onTurnComplete: () => {
events.push("onTurnComplete");
},
run: async ({ messages, signal }) => {
events.push("run");
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-lazy",
mode: "handover-prepare",
});
try {
// Before the signal lands, no hook should have fired.
await new Promise((r) => setTimeout(r, 20));
expect(events).toEqual([]);
await harness.sendHandover({
isFinal: false, // exercise the full streamText path
partialAssistantMessage: [
{ role: "assistant", content: [{ type: "text", text: "warming up" }] },
],
});
// Let any deferred onTurnComplete fire.
await new Promise((r) => setTimeout(r, 20));
// onPreload never fires for handover-prepare. Everything else
// fires once the partial lands — onChatStart still runs (first
// turn invariant), then onTurnStart, run, onTurnComplete.
expect(events).not.toContain("onPreload");
expect(events).toContain("onChatStart");
expect(events).toContain("onTurnStart");
expect(events).toContain("run");
expect(events).toContain("onTurnComplete");
// Order: hooks before run, run before onTurnComplete.
expect(events.indexOf("onTurnStart")).toBeLessThan(events.indexOf("run"));
expect(events.indexOf("run")).toBeLessThan(events.indexOf("onTurnComplete"));
} finally {
await harness.close();
}
});
it("idle timeout exits cleanly when no handover signal is sent", async () => {
// Customer's POST handler crashed before signaling. The agent
// should not hang forever — wait the configured idleTimeoutInSeconds
// and exit, just like the handover-skip case.
const onTurnStart = vi.fn();
const onTurnComplete = vi.fn();
const agent = chat.agent({
id: "chat.handover.idle-timeout",
idleTimeoutInSeconds: 1, // 1s — enough for the wait + exit.
onTurnStart,
onTurnComplete,
run: async ({ messages, signal }) => {
return streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("never") }),
}),
messages,
abortSignal: signal,
});
},
});
const harness = mockChatAgent(agent, {
chatId: "test-handover-timeout",
mode: "handover-prepare",
});
try {
// Wait long enough for the idle timeout to fire.
await new Promise((r) => setTimeout(r, 1500));
expect(onTurnStart).not.toHaveBeenCalled();
expect(onTurnComplete).not.toHaveBeenCalled();
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,426 @@
// Import the test harness FIRST — installs the resource catalog so the
// chat task functions below register correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { simulateReadableStream, streamText, tool } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import type { ModelMessage, UIMessage } from "ai";
import { z } from "zod";
// ── Helpers ────────────────────────────────────────────────────────────
function textStream(text: string): ReadableStream<LanguageModelV3StreamPart> {
return simulateReadableStream({
chunks: [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
],
});
}
type Capture = {
skipped?: boolean;
isFinal?: boolean;
handover?: { isFinal: boolean } | null;
uiMessages?: Array<{ id: string; role: string; partTypes: string[] }>;
};
function snapshot(uiMessages: UIMessage[]): Capture["uiMessages"] {
return uiMessages.map((m) => ({
id: m.id,
role: m.role,
partTypes: (m.parts ?? []).map((p) => p.type),
}));
}
// A pure-text partial (the warm step-1 response, isFinal: true).
const PURE_TEXT_PARTIAL: ModelMessage[] = [
{ role: "assistant", content: [{ type: "text", text: "Hi there, hope you're well." }] },
];
// A tool-call partial reshaped server-side into the approval round (isFinal: false).
const TOOL_CALL_PARTIAL: ModelMessage[] = [
{
role: "assistant",
content: [
{ type: "text", text: "let me check the weather" },
{ type: "tool-call", toolCallId: "tc-1", toolName: "weather", input: { city: "tokyo" } },
{
type: "tool-approval-request",
approvalId: "handover-approval-1",
toolCallId: "tc-1",
} as never,
],
},
{
role: "tool",
content: [
{
type: "tool-approval-response",
approvalId: "handover-approval-1",
approved: true,
} as never,
],
},
];
function weatherToolWithExecute(execute: (input: { city: string }) => Promise<unknown>) {
return tool({
description: "Look up weather",
inputSchema: z.object({ city: z.string() }),
execute: execute as never,
});
}
// ── chat.customAgent + headStart handover ───────────────────────────────
describe("chat.customAgent + headStart handover", () => {
it("consumeHandover skip → clean exit, no turn-complete", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.skip",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
runAfter();
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-skip", mode: "handover-prepare" });
try {
await harness.sendHandoverSkip();
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(true);
expect(capture.isFinal).toBe(false);
expect(runAfter).not.toHaveBeenCalled();
expect(harness.allChunks).toHaveLength(0);
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: true (pure text) → partial spliced, no streamText", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "custom.handover.final",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
capture.uiMessages = snapshot(conversation.uiMessages);
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
runAfter();
},
});
const harness = mockChatAgent(agent, { chatId: "t-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-1",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.skipped).toBe(false);
expect(capture.isFinal).toBe(true);
// The warm step-1 partial is in the accumulator under its messageId.
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-1", role: "assistant", partTypes: ["text"] },
]);
// isFinal means no streamText.
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("consumeHandover isFinal: false (tool call) → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "custom.handover.toolcall",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
const { isFinal, skipped } = await conversation.consumeHandover({ payload });
capture.skipped = skipped;
capture.isFinal = isFinal;
if (skipped) return;
if (isFinal) {
await chat.writeTurnComplete();
return;
}
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: conversation.modelMessages,
tools: { weather: weatherToolWithExecute(toolExecute) },
});
await chat.pipeAndCapture(result);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
// Handover consumed as non-final, and the handed-over tool round resumed:
// the agent-side `execute` ran on the pending tool-call from step 1
// (schema-only-on-warm-handler pattern). The full step-2-text-through-handover
// path is verified end-to-end by the ai-chat-e2e smoke test (T29).
expect(capture.isFinal).toBe(false);
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
it("addResponse replaces the spliced partial in place when the resume reuses its id", async () => {
// On a non-final handover resume the pipe threads originalMessages, so the
// captured response carries the SAME id as the spliced partial. addResponse
// must replace it, not append a duplicate (else the persisted accumulator
// ends up with two assistant messages — caught live by T29, not the mock pipe).
const capture: Capture = {};
const agent = chat.customAgent({
id: "custom.handover.addresponse-dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
// Simulate the merged step-2 response reusing the partial's id.
await conversation.addResponse({
id: "asst-msg-2",
role: "assistant",
parts: [
{ type: "text", text: "the weather in tokyo is 22°C" },
{
type: "tool-weather",
toolCallId: "tc-1",
state: "output-available",
input: { city: "tokyo" },
output: { city: "tokyo", temp: 22 },
} as never,
],
});
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, { chatId: "t-addresp-dedup", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-2",
isFinal: false,
});
await new Promise((r) => setTimeout(r, 20));
// Exactly one assistant message under the handover id — replaced, not doubled.
expect(capture.uiMessages!.filter((m) => m.id === "asst-msg-2")).toHaveLength(1);
expect(capture.uiMessages!.filter((m) => m.role === "assistant")).toHaveLength(1);
// And it carries the merged step-2 content (text + resolved tool output).
expect(capture.uiMessages!.at(-1)?.partTypes).toEqual(["text", "tool-weather"]);
} finally {
await harness.close();
}
});
it("seeds payload.headStartMessages before splicing the partial", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
];
const agent = chat.customAgent({
id: "custom.handover.seed",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-seed",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-3",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Prior history first, then the warm partial.
expect(capture.uiMessages).toEqual([
{ id: "u-1", role: "user", partTypes: ["text"] },
{ id: "asst-msg-3", role: "assistant", partTypes: ["text"] },
]);
} finally {
await harness.close();
}
});
it("dedups the partial when headStartMessages already carries its messageId", async () => {
const capture: Capture = {};
const prior: UIMessage[] = [
{ id: "u-1", role: "user", parts: [{ type: "text", text: "hello" }] },
// Already-persisted partial under the same id the handover uses.
{
id: "asst-dup",
role: "assistant",
parts: [{ type: "text", text: "Hi there, hope you're well." }],
},
];
const agent = chat.customAgent({
id: "custom.handover.dedup",
run: async (payload) => {
const conversation = new chat.MessageAccumulator();
await conversation.consumeHandover({ payload });
capture.uiMessages = snapshot(conversation.uiMessages);
await chat.writeTurnComplete();
},
});
const harness = mockChatAgent(agent, {
chatId: "t-dedup",
mode: "handover-prepare",
headStartMessages: prior,
});
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-dup",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
// Not doubled — still just the two seeded messages.
expect(capture.uiMessages).toHaveLength(2);
expect(capture.uiMessages!.filter((m) => m.id === "asst-dup")).toHaveLength(1);
} finally {
await harness.close();
}
});
});
// ── chat.createSession + headStart handover ──────────────────────────────
describe("chat.createSession + headStart handover", () => {
it("turn.handover.isFinal: true → complete() with no source finalizes the partial", async () => {
const capture: Capture = {};
const runAfter = vi.fn();
const agent = chat.customAgent({
id: "session.handover.final",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
capture.uiMessages = snapshot(turn.uiMessages);
if (turn.handover?.isFinal) {
await turn.complete();
return;
}
runAfter();
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-final", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: PURE_TEXT_PARTIAL,
messageId: "asst-msg-4",
isFinal: true,
});
await new Promise((r) => setTimeout(r, 20));
expect(capture.handover).toEqual({ isFinal: true });
expect(capture.uiMessages).toEqual([
{ id: "asst-msg-4", role: "assistant", partTypes: ["text"] },
]);
expect(runAfter).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("turn.handover.isFinal: false → streamText runs the handed-over tool round", async () => {
const capture: Capture = {};
const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ city, temp: 22 }));
const agent = chat.customAgent({
id: "session.handover.toolcall",
run: async (payload) => {
const session = chat.createSession(payload, { signal: new AbortController().signal });
for await (const turn of session) {
capture.handover = turn.handover;
const result = streamText({
model: new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("the weather in tokyo is 22°C") }),
}),
messages: turn.messages,
tools: { weather: weatherToolWithExecute(toolExecute) },
abortSignal: turn.signal,
});
await turn.complete(result);
return;
}
},
});
const harness = mockChatAgent(agent, { chatId: "s-tool", mode: "handover-prepare" });
try {
await harness.sendHandover({
partialAssistantMessage: TOOL_CALL_PARTIAL,
messageId: "asst-msg-5",
isFinal: false,
});
// Surfaced as a non-final handover turn, and the handed-over tool round
// resumed (agent-side execute ran). Full step-2-text path covered by T29.
expect(capture.handover).toEqual({ isFinal: false });
expect(toolExecute).toHaveBeenCalledWith(
expect.objectContaining({ city: "tokyo" }),
expect.anything()
);
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,123 @@
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import ts from "typescript";
/**
* Regression test for declaration-emit portability (customer TS2742).
*
* Simulates a real consumer: the BUILT package (dist + package.json) is
* copied (not symlinked — tsc's module-specifier generation only uses the
* exports map for files under node_modules real paths) into a temp
* project's node_modules, then a chat-builder agent export is compiled
* with `declaration: true`. Every type appearing in the inferred public
* surface must be nameable through a public package specifier; a type
* declared in a module that isn't reachable via the exports map produces
* a relative-path import in the emit and TS2742 for consumers.
*/
const packageRoot = resolve(__dirname, "..");
const distDir = join(packageRoot, "dist");
const coreRoot = resolve(packageRoot, "../core");
const FIXTURE_SOURCE = `
import { chat } from "@trigger.dev/sdk/ai";
import { streamText } from "ai";
import type { UIMessage } from "ai";
import { z } from "zod";
type FixtureUIMessage = UIMessage<never, { kind: { value: string } }>;
export const fixtureAgent = chat
.withUIMessage<FixtureUIMessage>()
.withClientData({ schema: z.object({ userId: z.string() }) })
.agent({
id: "fixture-agent",
run: async ({ messages, signal }) => {
return streamText({ model: "openai/gpt-5" as never, messages, abortSignal: signal });
},
});
`;
describe("declaration emit portability", () => {
it.skipIf(!existsSync(distDir) || !existsSync(join(coreRoot, "dist")))(
"emits portable declarations for inferred chat agent types",
() => {
const consumerDir = mkdtempSync(join(tmpdir(), "sdk-decl-emit-"));
try {
const scopedDir = join(consumerDir, "node_modules", "@trigger.dev");
mkdirSync(scopedDir, { recursive: true });
for (const [name, root] of [
["sdk", packageRoot],
["core", coreRoot],
] as const) {
const target = join(scopedDir, name);
mkdirSync(target, { recursive: true });
cpSync(join(root, "package.json"), join(target, "package.json"));
cpSync(join(root, "dist"), join(target, "dist"), { recursive: true });
}
// Third-party type deps resolve fine through symlinks.
for (const dep of ["ai", "zod", "@ai-sdk/provider"]) {
const source = realpathSync(join(packageRoot, "node_modules", dep));
const target = join(consumerDir, "node_modules", dep);
mkdirSync(resolve(target, ".."), { recursive: true });
symlinkSync(source, target);
}
const fixturePath = join(consumerDir, "agent.ts");
ts.sys.writeFile(fixturePath, FIXTURE_SOURCE);
const emitted = new Map<string, string>();
const host = ts.createCompilerHost({});
host.writeFile = (fileName, text) => emitted.set(fileName, text);
const program = ts.createProgram({
rootNames: [fixturePath],
options: {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
strict: true,
declaration: true,
emitDeclarationOnly: true,
skipLibCheck: true,
outDir: join(consumerDir, "out"),
rootDir: consumerDir,
},
host,
});
const emitResult = program.emit();
const diagnostics = [
...ts.getPreEmitDiagnostics(program),
...emitResult.diagnostics,
].filter((d) => d.category === ts.DiagnosticCategory.Error);
const formatted = diagnostics.map((d) =>
ts.flattenDiagnosticMessageText(d.messageText, "\n")
);
expect(formatted).toEqual([]);
const dts = [...emitted.entries()].find(([name]) => name.endsWith("agent.d.ts"))?.[1];
expect(dts).toBeDefined();
// The payload generic must be named via the public subpath, and the
// emit must not fall back to file paths into the package.
expect(dts).toContain('import("@trigger.dev/sdk/chat").ChatTaskWirePayload');
expect(dts).not.toMatch(/import\("\.{1,2}\//);
expect(dts).not.toContain("ai-shared");
} finally {
rmSync(consumerDir, { recursive: true, force: true });
}
}
);
});
@@ -0,0 +1,160 @@
// Plan F.1: pure-function correctness tests for `mergeByIdReplaceWins`,
// the helper that combines `snapshot.messages` with `session.out` replay
// at run boot (plan section B.3). Replay wins on id collision because
// `session.out` carries the freshest representation of an assistant
// message.
import "../src/v3/test/index.js";
import type { UIMessage } from "ai";
import { describe, expect, it } from "vitest";
import { __mergeByIdReplaceWinsForTests as mergeByIdReplaceWins } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(id: string, text: string): UIMessage {
return {
id,
role: "user",
parts: [{ type: "text", text }],
};
}
function assistantMessage(id: string, text: string): UIMessage {
return {
id,
role: "assistant",
parts: [{ type: "text", text }],
};
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("mergeByIdReplaceWins", () => {
it("returns a copy of `a` when `b` is empty", () => {
const a = [userMessage("u-1", "hello")];
const result = mergeByIdReplaceWins(a, []);
expect(result).toEqual(a);
// Verify it's a copy (mutating result shouldn't touch a).
result.push(assistantMessage("a-1", "extra"));
expect(a).toHaveLength(1);
});
it("returns a copy of `b` when `a` is empty", () => {
const b = [assistantMessage("a-1", "world")];
const result = mergeByIdReplaceWins([], b);
expect(result).toEqual(b);
result.push(userMessage("u-extra", "extra"));
expect(b).toHaveLength(1);
});
it("returns [] when both inputs are empty", () => {
expect(mergeByIdReplaceWins([], [])).toEqual([]);
});
it("appends fresh ids from `b` after `a`'s entries", () => {
const a = [userMessage("u-1", "hi")];
const b = [assistantMessage("a-1", "ok")];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1"]);
expect(result[0]!.role).toBe("user");
expect(result[1]!.role).toBe("assistant");
});
it("replaces by id when `b` has a colliding entry — replay wins", () => {
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale-version")];
const b = [assistantMessage("a-1", "fresh-version")];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(2);
expect(result[1]!.id).toBe("a-1");
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-version");
});
it("preserves order from `a` even when entries are replaced", () => {
const a = [
userMessage("u-1", "first"),
assistantMessage("a-1", "stale"),
userMessage("u-2", "second"),
assistantMessage("a-2", "also-stale"),
];
const b = [assistantMessage("a-1", "fresh-1"), assistantMessage("a-2", "fresh-2")];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-1");
expect((result[3]!.parts[0] as { text: string }).text).toBe("fresh-2");
});
it("appends `b` entries with no id collision after the merged set", () => {
const a = [userMessage("u-1", "first")];
const b = [
assistantMessage("a-1", "reply-1"),
userMessage("u-2", "second"),
assistantMessage("a-2", "reply-2"),
];
const result = mergeByIdReplaceWins(a, b);
expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]);
});
it("treats messages without an id as always-append (no collision possible)", () => {
const a = [
userMessage("u-1", "first"),
// Synthetic message missing the id field — should append, never replace.
{
id: "" as string,
role: "assistant",
parts: [{ type: "text", text: "no-id-a" }],
} as UIMessage,
];
const b = [
{
id: "" as string,
role: "assistant",
parts: [{ type: "text", text: "no-id-b" }],
} as UIMessage,
];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(3);
// Both empty-id messages survive — no merge happens.
const noIdParts = result
.filter((m) => m.id === "")
.map((m) => (m.parts[0] as { text: string }).text);
expect(noIdParts).toEqual(["no-id-a", "no-id-b"]);
});
it("handles consecutive replays of the same id in `b` — last one wins", () => {
// Edge case: `b` has two entries with the same id (shouldn't happen
// for assistants in practice, but the helper must be deterministic).
const a = [assistantMessage("a-1", "v0")];
const b = [assistantMessage("a-1", "v1"), assistantMessage("a-1", "v2")];
const result = mergeByIdReplaceWins(a, b);
expect(result).toHaveLength(1);
expect((result[0]!.parts[0] as { text: string }).text).toBe("v2");
});
it("preserves user messages (only assistants come from replay) — semantic check", () => {
// The runtime contract: `session.out` contains assistant chunks only,
// so `b` should never contain user messages. If it does (defensively),
// the merge still works — but we lock down the typical pattern here.
const a = [
userMessage("u-1", "first"),
assistantMessage("a-1", "stale"),
userMessage("u-2", "second"),
];
const b = [assistantMessage("a-1", "fresh")];
const result = mergeByIdReplaceWins(a, b);
// User messages from snapshot survive untouched.
expect(result.filter((m) => m.role === "user").map((m) => m.id)).toEqual(["u-1", "u-2"]);
});
it("does not mutate either input array", () => {
const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale")];
const b = [assistantMessage("a-1", "fresh"), userMessage("u-2", "next")];
const aSnapshot = JSON.stringify(a);
const bSnapshot = JSON.stringify(b);
mergeByIdReplaceWins(a, b);
expect(JSON.stringify(a)).toBe(aSnapshot);
expect(JSON.stringify(b)).toBe(bSnapshot);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,273 @@
// Import the test harness FIRST — this installs the resource catalog so
// `chat.agent()` calls below register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import { describe, expect, it, vi } from "vitest";
import { chat } from "../src/v3/ai.js";
import { __setSessionOpenImplForTests, sessions } from "../src/v3/sessions.js";
import { apiClientManager, sessionStreams } from "@trigger.dev/core/v3";
import { runInMockTaskContext } from "@trigger.dev/core/v3/test";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(text: string, id: string) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStreamChunks(text: string): LanguageModelV3StreamPart[] {
return [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
}
/** Model that answers `ANSWER(<last user text>)`, slowly enough that
* records sent right after the turn starts arrive mid-stream. */
function echoModel() {
return new MockLanguageModelV3({
doStream: async ({ prompt }) => {
const users = prompt.filter((m) => m.role === "user");
const last = users[users.length - 1];
const text = Array.isArray(last?.content)
? last.content
.filter((p): p is { type: "text"; text: string } => p.type === "text")
.map((p) => p.text)
.join("")
: "";
return {
stream: simulateReadableStream({
chunks: textStreamChunks(`ANSWER(${text})`),
initialDelayInMs: 100,
chunkDelayInMs: 10,
}),
};
},
});
}
async function waitFor(check: () => boolean, timeoutMs = 10_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (check()) return;
await new Promise((r) => setTimeout(r, 20));
}
throw new Error("waitFor timed out");
}
function streamedText(harness: { allChunks: unknown[] }): string {
return (harness.allChunks as { type?: string; delta?: string }[])
.filter((c) => c.type === "text-delta")
.map((c) => c.delta ?? "")
.join("");
}
function turnCompleteCount(harness: { allRawChunks: unknown[] }): number {
return (harness.allRawChunks as { type?: string }[]).filter(
(c) => c.type === "trigger:turn-complete"
).length;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("chat.agent pending wire buffer", () => {
it("dispatches every message buffered during a turn, not just the first", async () => {
const agent = chat.agent({
id: "pending-drain.agent",
run: async ({ messages, signal }) => {
return streamText({ model: echoModel(), messages, abortSignal: signal });
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-1" });
try {
const first = harness.sendMessage(userMessage("m1", "u-1"));
// Once m1's turn is streaming, land two more records back-to-back —
// both are consumed into the turn's buffer before the turn ends.
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
void harness.sendMessage(userMessage("m2", "u-2"));
void harness.sendMessage(userMessage("m3", "u-3"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 3);
const text = streamedText(harness);
const m2At = text.indexOf("ANSWER(m2)");
const m3At = text.indexOf("ANSWER(m3)");
expect(m2At).toBeGreaterThan(-1);
expect(m3At).toBeGreaterThan(-1);
expect(m3At).toBeGreaterThan(m2At);
} finally {
await harness.close();
}
});
});
describe("chat.agent errored turn", () => {
it(
"does not duplicate messages buffered after a turn that threw",
{ timeout: 20000 },
async () => {
// Throw from a pre-stream hook: throws inside the streaming section are
// already covered by its finally, but a hook throw used to leak the
// turn's message handler into the loop-level buffer.
let turnStarts = 0;
const agent = chat.agent({
id: "pending-drain.errored-turn",
onTurnStart: async () => {
turnStarts++;
if (turnStarts === 1) {
throw new Error("synthetic turn failure");
}
},
run: async ({ messages, signal }) => {
return streamText({ model: echoModel(), messages, abortSignal: signal });
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-4" });
try {
// Turn 1 throws — pre-fix its message handler leaked past the turn.
await harness.sendMessage(userMessage("boom", "u-1"));
const second = harness.sendMessage(userMessage("m2", "u-2"));
// m3 lands mid-turn; a leaked handler would push it twice.
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
void harness.sendMessage(userMessage("m3", "u-3"));
await second;
await waitFor(() => streamedText(harness).includes("ANSWER(m3)"));
await new Promise((r) => setTimeout(r, 500));
const text = streamedText(harness);
expect(text.match(/ANSWER\(m3\)/g)).toHaveLength(1);
} finally {
await harness.close();
}
}
);
});
describe("chat.createSession pending wire buffer", () => {
it("dispatches messages buffered during a turn as subsequent turns", async () => {
const agent = chat.customAgent({
id: "pending-drain.session",
run: async (payload) => {
const session = chat.createSession(payload, {
signal: new AbortController().signal,
idleTimeoutInSeconds: 2,
});
for await (const turn of session) {
const result = streamText({
model: echoModel(),
messages: turn.messages,
abortSignal: turn.signal,
});
await turn.complete(result);
}
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-2" });
try {
const first = harness.sendMessage(userMessage("m1", "u-1"));
await waitFor(() => streamedText(harness).includes("ANSWER(m1)"));
void harness.sendMessage(userMessage("m2", "u-2"));
void harness.sendMessage(userMessage("m3", "u-3"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 3);
const text = streamedText(harness);
expect(text).toContain("ANSWER(m2)");
expect(text).toContain("ANSWER(m3)");
} finally {
await harness.close();
}
});
});
describe("chat.createSession stop + immediate send", () => {
it(
"dispatches a message that arrives right after a stopped turn",
{ timeout: 20000 },
async () => {
const agent = chat.customAgent({
id: "pending-drain.session-stop",
run: async (payload) => {
const session = chat.createSession(payload, {
signal: new AbortController().signal,
idleTimeoutInSeconds: 2,
// Steering config active — the failure mode routed post-stream
// arrivals into the dead steering queue instead of the next turn.
pendingMessages: {},
});
for await (const turn of session) {
const result = streamText({
model: echoModel(),
messages: turn.messages,
abortSignal: turn.signal,
});
await turn.complete(result);
}
},
});
const harness = mockChatAgent(agent, { chatId: "pending-drain-3" });
try {
const first = harness.sendMessage(userMessage("write a long essay", "u-1"));
await waitFor(() => streamedText(harness).length > 0);
await harness.sendStop();
// Land the next message inside the stopped turn's post-stream window
// (the ~2s totalUsage race), after the abort has settled — previously
// the still-attached handler steering-routed it into the dead queue.
await new Promise((r) => setTimeout(r, 150));
void harness.sendMessage(userMessage("m2", "u-2"));
await first;
await waitFor(() => turnCompleteCount(harness) >= 2);
await waitFor(() => streamedText(harness).includes("ANSWER(m2)"));
} finally {
await harness.close();
}
}
);
});
describe("session.in.wait() consume cursor", () => {
it("advances lastDispatchedSeqNum alongside lastSeqNum on waitpoint delivery", async () => {
__setSessionOpenImplForTests(undefined);
await runInMockTaskContext(async () => {
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
createSessionStreamWaitpoint: async () => ({ waitpointId: "wp_test_1" }),
waitForWaitpointToken: async () => ({ success: true }),
} as never);
const sessionId = "cursor-sess";
// Simulate records 0..4 already received via SSE before the suspend.
sessionStreams.setLastSeqNum(sessionId, "in", 4);
const result = await sessions.open(sessionId).in.wait();
expect(result.ok).toBe(true);
expect(sessionStreams.lastSeqNum(sessionId, "in")).toBe(5);
// The waitpoint-delivered record was consumed by this caller, so the
// committed-consume cursor (what turn-completes persist as
// `session-in-event-id`) must advance with it.
expect(sessionStreams.lastDispatchedSeqNum(sessionId, "in")).toBe(5);
});
});
});
@@ -0,0 +1,211 @@
// Import the test harness FIRST so the resource catalog is installed
import { mockChatAgent } from "../src/v3/test/index.js";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { describe, expect, it } from "vitest";
import { chat } from "../src/v3/ai.js";
function userMessage(text: string, id?: string) {
return {
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
/** Capture the rendered system message handed to the provider. */
type Captured = { system?: { role: string; content: unknown; providerOptions?: any } };
function makeModel(capture: Captured) {
return new MockLanguageModelV3({
doStream: async (opts) => {
capture.system = opts.prompt.find((m) => m.role === "system") as Captured["system"];
return { stream: textStream("ok") };
},
});
}
/** Poll until the mock model captures the system message (bounded), instead of a fixed sleep. */
async function waitForSystemCaptured(capture: Captured, timeoutMs = 1000, intervalMs = 5) {
const startedAt = Date.now();
while (!capture.system) {
if (Date.now() - startedAt > timeoutMs) {
throw new Error("Timed out waiting for system message capture");
}
await new Promise((r) => setTimeout(r, intervalMs));
}
}
const SYSTEM = "You are a helpful assistant for tests.";
describe("chat prompt caching — system providerOptions", () => {
it("emits a plain system prompt with no providerOptions by default", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.default",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }),
});
const harness = mockChatAgent(agent, { chatId: "pc-default" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.content).toContain("helpful assistant");
expect(cap.system?.providerOptions).toBeUndefined();
} finally {
await harness.close();
}
});
it("attaches cacheControl via the toStreamTextOptions sugar", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.sugar",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({ cacheControl: { type: "ephemeral" } }),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-sugar" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.content).toContain("helpful assistant");
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({ type: "ephemeral" });
} finally {
await harness.close();
}
});
it("attaches systemProviderOptions verbatim", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.explicit",
onChatStart: async () => {
chat.prompt.set(SYSTEM);
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({
systemProviderOptions: {
anthropic: { cacheControl: { type: "ephemeral", ttl: "1h" } },
},
}),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-explicit" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({
type: "ephemeral",
ttl: "1h",
});
} finally {
await harness.close();
}
});
it("carries providerOptions set on chat.prompt.set()", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.prompt-set",
onChatStart: async () => {
chat.prompt.set(SYSTEM, {
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
},
run: async ({ messages, signal }) =>
streamText({ model, messages, abortSignal: signal, ...chat.toStreamTextOptions() }),
});
const harness = mockChatAgent(agent, { chatId: "pc-prompt-set" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({ type: "ephemeral" });
} finally {
await harness.close();
}
});
it("call-site systemProviderOptions overrides chat.prompt.set providerOptions", async () => {
const cap: Captured = {};
const model = makeModel(cap);
const agent = chat.agent({
id: "prompt-caching.precedence",
onChatStart: async () => {
chat.prompt.set(SYSTEM, {
providerOptions: { anthropic: { cacheControl: { type: "ephemeral" } } },
});
},
run: async ({ messages, signal }) =>
streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions({
systemProviderOptions: {
anthropic: { cacheControl: { type: "ephemeral", ttl: "1h" } },
},
}),
}),
});
const harness = mockChatAgent(agent, { chatId: "pc-precedence" });
try {
await harness.sendMessage(userMessage("hi"));
await waitForSystemCaptured(cap);
// The call-site option wins (ttl: "1h"), not the prompt-set default.
expect(cap.system?.providerOptions?.anthropic?.cacheControl).toEqual({
type: "ephemeral",
ttl: "1h",
});
} finally {
await harness.close();
}
});
});
@@ -0,0 +1,468 @@
// Import the test harness FIRST — installs the resource catalog so
// `chat.agent()` calls register their task functions correctly.
import { mockChatAgent } from "../src/v3/test/index.js";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { simulateReadableStream, streamText } from "ai";
import { MockLanguageModelV3 } from "ai/test";
import { describe, expect, it, vi } from "vitest";
import type { RecoveryBootEvent, RecoveryBootResult } from "../src/v3/ai.js";
import { __setReplaySessionOutTailImplForTests, chat } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(text: string, id = "u-" + Math.random().toString(36).slice(2)) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function assistantMessage(text: string, id = "a-" + Math.random().toString(36).slice(2)) {
return {
id,
role: "assistant" as const,
parts: [{ type: "text" as const, text }],
};
}
function partialAssistantWithToolCall(id: string, toolCallId: string, toolName: string) {
return {
id,
role: "assistant" as const,
parts: [
{
type: `tool-${toolName}` as const,
toolCallId,
state: "input-available" as const,
input: { q: "search" },
},
],
} as unknown as ReturnType<typeof assistantMessage>;
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("onRecoveryBoot — chat.agent recovery hook", () => {
it("does NOT fire on a clean continuation with no recovered state", async () => {
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const agent = chat.agent({
id: "recovery-boot.no-state",
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "no-state",
continuation: true,
previousRunId: "run_prior",
});
try {
// Snapshot is empty, no in-flight users, no partial — guard
// (partialAssistant !== undefined || inFlightUsers.length > 0) is false.
await harness.sendMessage(userMessage("fresh message"));
await new Promise((r) => setTimeout(r, 20));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("fires when there's a partial assistant and surfaces it on the ctx", async () => {
const captured: { event?: RecoveryBootEvent<ReturnType<typeof userMessage>> } = {};
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("recovered") }),
});
const partial = partialAssistantWithToolCall("a-orphan", "tc-1", "search");
const agent = chat.agent({
id: "recovery-boot.partial-fires-hook",
onRecoveryBoot: async (event) => {
captured.event = event as never;
return {};
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "partial-fires-hook",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
try {
await harness.sendMessage(userMessage("next user message"));
await new Promise((r) => setTimeout(r, 20));
expect(captured.event).toBeDefined();
expect(captured.event!.partialAssistant?.id).toBe("a-orphan");
expect(captured.event!.pendingToolCalls).toHaveLength(1);
expect(captured.event!.pendingToolCalls[0]!.toolCallId).toBe("tc-1");
expect(captured.event!.pendingToolCalls[0]!.toolName).toBe("search");
expect(captured.event!.previousRunId).toBe("run_prior");
expect(captured.event!.cause).toBe("unknown");
} finally {
await harness.close();
}
});
it("pendingToolCalls is extracted from the RAW partial (pre-cleanupAbortedParts)", async () => {
// Real-world scenario: cancel-mid-tool-call. Session.out has tool-call
// chunks but the tool never returned. cleanupAbortedParts strips the
// input-available tool part from the partial used for the chain (you
// don't want orphan tool calls poisoning the model context), but
// `pendingToolCalls` should still surface what was happening.
const cleanedPartial = {
id: "a-orphan",
role: "assistant" as const,
parts: [{ type: "text" as const, text: "Let me look that up" }],
};
const rawPartial = {
id: "a-orphan",
role: "assistant" as const,
parts: [
{ type: "text" as const, text: "Let me look that up" },
{
type: "tool-search" as const,
toolCallId: "tc-pending",
state: "input-available" as const,
input: { q: "vietnamese pho" },
},
],
} as unknown as typeof cleanedPartial;
const captured: { event?: RecoveryBootEvent } = {};
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.pending-tool-from-raw",
onRecoveryBoot: async (event) => {
captured.event = event;
return {};
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "pending-tool-from-raw",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
// Install AFTER mockChatAgent — its constructor sets its own default
// override that we want to replace for this test.
__setReplaySessionOutTailImplForTests(
async () =>
({
settled: [],
partial: cleanedPartial,
partialRaw: rawPartial,
}) as never
);
try {
await new Promise((r) => setTimeout(r, 50));
expect(captured.event).toBeDefined();
// Cleaned partial → chain (no input-available tool part)
expect(captured.event!.partialAssistant?.parts).toHaveLength(1);
// pendingToolCalls → from raw (input-available tool part visible)
expect(captured.event!.pendingToolCalls).toHaveLength(1);
expect(captured.event!.pendingToolCalls[0]!.toolCallId).toBe("tc-pending");
expect(captured.event!.pendingToolCalls[0]!.toolName).toBe("search");
} finally {
await harness.close();
}
});
it("does NOT fire when there are in-flight users but no partial (graceful exit path)", async () => {
// chat.requestUpgrade(), chat.endRun() before processing, and similar
// graceful exits leave an unacknowledged user on session.in but no
// partial assistant on session.out. That's not recovery — the next
// run just dispatches the message normally.
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered while dead", "u-buffered");
const agent = chat.agent({
id: "recovery-boot.inflight-users-no-partial",
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "inflight-users-no-partial",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("default behavior re-dispatches each in-flight user as a turn", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream(`reply ${turnCount}`) };
},
});
const u1 = userMessage("first buffered", "u-1");
const u2 = userMessage("second buffered", "u-2");
const agent = chat.agent({
id: "recovery-boot.default-dispatch",
// NO onRecoveryBoot — exercise the default path
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "default-dispatch",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
expect(turnCount).toBe(2);
} finally {
await harness.close();
}
});
it("smart default: partial + first user spliced into chain, rest dispatched", async () => {
let observedChain: Array<{ role: string; idHead: string }> = [];
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial answer in progress", "a-partial");
const u1 = userMessage("original question", "u-1");
const u2 = userMessage("follow-up", "u-2");
const agent = chat.agent({
id: "recovery-boot.smart-default",
// NO onRecoveryBoot — exercise the smart default
onTurnStart: async ({ uiMessages }) => {
if (turnCount === 0) {
observedChain = uiMessages.map((m) => ({
role: m.role,
idHead: m.id.slice(0, 10),
}));
}
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "smart-default",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
// Turn 1 fires with the follow-up user (u2). Its chain should
// include [u1 (original), a-partial, u2 (follow-up)].
expect(turnCount).toBe(1);
expect(observedChain.map((m) => m.role)).toEqual(["user", "assistant", "user"]);
expect(observedChain[0]!.idHead).toBe("u-1");
expect(observedChain[1]!.idHead).toBe("a-partial");
expect(observedChain[2]!.idHead).toBe("u-2");
} finally {
await harness.close();
}
});
it("hook's recoveredTurns: [] suppresses re-dispatch of in-flight users", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream(`reply ${turnCount}`) };
},
});
const partial = assistantMessage("partial answer", "a-partial");
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.suppress-dispatch",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({ recoveredTurns: [] }),
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "suppress-dispatch",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never]);
try {
// No turn should fire from the boot-injected queue.
// Send a fresh user message to confirm the agent is alive.
await harness.sendMessage(userMessage("real next message"));
await new Promise((r) => setTimeout(r, 20));
expect(turnCount).toBe(1); // only the explicit sendMessage turn
} finally {
await harness.close();
}
});
it("hook's chain override seeds the accumulator", async () => {
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("acked") }),
});
const custom = assistantMessage("custom-recovered-history", "a-custom");
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered", "u-1");
let observedMessageCount = 0;
const agent = chat.agent({
id: "recovery-boot.chain-override",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({
chain: [custom as never],
recoveredTurns: [u1 as never],
}),
onTurnStart: async ({ uiMessages }) => {
observedMessageCount = uiMessages.length;
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "chain-override",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
harness.seedSessionInTail([u1 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
// Chain seeded with [custom] before the recovered user message
// arrives — onTurnStart sees [custom, u1] when the first
// recovered turn fires.
expect(observedMessageCount).toBe(2);
} finally {
await harness.close();
}
});
it("does NOT fire when hydrateMessages is registered", async () => {
const onRecoveryBoot = vi.fn();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: textStream("ok") }),
});
const u1 = userMessage("buffered", "u-1");
const agent = chat.agent({
id: "recovery-boot.hydrate-skips",
hydrateMessages: async ({ incomingMessages }) => incomingMessages,
onRecoveryBoot,
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "hydrate-skips",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionInTail([u1 as never]);
try {
await harness.sendMessage(userMessage("fresh"));
await new Promise((r) => setTimeout(r, 20));
expect(onRecoveryBoot).not.toHaveBeenCalled();
} finally {
await harness.close();
}
});
it("beforeBoot runs before the first recovered turn fires", async () => {
const order: string[] = [];
const model = new MockLanguageModelV3({
doStream: async () => {
order.push("turn");
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered original", "u-1");
const u2 = userMessage("followup", "u-2");
const agent = chat.agent({
id: "recovery-boot.before-boot",
onRecoveryBoot: async (): Promise<RecoveryBootResult> => ({
beforeBoot: async () => {
order.push("beforeBoot");
},
}),
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "before-boot",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
// Two users — smart default consumes u1 into the chain, leaves u2 for dispatch
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 50));
expect(order).toEqual(["beforeBoot", "turn"]);
} finally {
await harness.close();
}
});
it("hook throwing falls back to defaults without sinking the run", async () => {
let turnCount = 0;
const model = new MockLanguageModelV3({
doStream: async () => {
turnCount++;
return { stream: textStream("ok") };
},
});
const partial = assistantMessage("partial", "a-partial");
const u1 = userMessage("buffered original", "u-1");
const u2 = userMessage("followup", "u-2");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const agent = chat.agent({
id: "recovery-boot.hook-throws",
onRecoveryBoot: async () => {
throw new Error("kaboom");
},
run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }),
});
const harness = mockChatAgent(agent, {
chatId: "hook-throws",
continuation: true,
previousRunId: "run_prior",
});
harness.seedSessionOutPartial(partial as never);
// Two users so smart default leaves u2 to dispatch (u1 spliced into chain)
harness.seedSessionInTail([u1 as never, u2 as never]);
try {
await new Promise((r) => setTimeout(r, 100));
// Default behavior: the in-flight user is re-dispatched as a turn
// even though the hook threw.
expect(turnCount).toBe(1);
} finally {
await harness.close();
warnSpy.mockRestore();
}
});
});
@@ -0,0 +1,225 @@
// Import the test entry point first so the resource catalog is installed.
import "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import {
__findLatestSessionInCursorForTests as findLatestSessionInCursor,
__replaySessionInTailProductionPathForTests as replaySessionInTail,
} from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
function userMessage(id: string, text: string) {
return {
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function stubReadRecords(chunks: unknown[]) {
const records = chunks.map((chunk, i) => ({
data: chunk,
id: `evt-${i + 1}`,
seqNum: i + 1,
}));
const spy = vi.fn(async () => ({ records }));
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: spy,
} as never);
return spy;
}
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Tests ──────────────────────────────────────────────────────────────
describe("replaySessionInTail", () => {
it("extracts user messages from kind: 'message' records with submit-message trigger", async () => {
const u1 = userMessage("u-1", "hello");
const u2 = userMessage("u-2", "again");
stubReadRecords([
{
kind: "message",
payload: {
chatId: "c1",
trigger: "submit-message",
message: u1,
metadata: { userId: "a" },
},
},
{
kind: "message",
payload: {
chatId: "c1",
trigger: "submit-message",
message: u2,
metadata: { userId: "b" },
},
},
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(2);
expect(result[0]!.message.id).toBe("u-1");
expect(result[0]!.seqNum).toBe(1);
expect(result[0]!.metadata).toEqual({ userId: "a" });
expect(result[1]!.message.id).toBe("u-2");
expect(result[1]!.seqNum).toBe(2);
expect(result[1]!.metadata).toEqual({ userId: "b" });
});
it("ignores non-message variants (stop, handover, handover-skip)", async () => {
const u1 = userMessage("u-1", "real user");
stubReadRecords([
{ kind: "stop", message: "user stopped" },
{ kind: "handover-skip" },
{ kind: "handover", partialAssistantMessage: [], isFinal: false },
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: u1 } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(1);
expect(result[0]!.message.id).toBe("u-1");
});
it("ignores message records that aren't submit-message", async () => {
// regenerate-message / preload / close / action / handover-prepare don't
// carry a user message — the chain reconstruction must skip them.
stubReadRecords([
{ kind: "message", payload: { chatId: "c1", trigger: "regenerate-message" } },
{ kind: "message", payload: { chatId: "c1", trigger: "preload" } },
{ kind: "message", payload: { chatId: "c1", trigger: "close" } },
{ kind: "message", payload: { chatId: "c1", trigger: "action", action: { foo: 1 } } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(0);
});
it("ignores records whose payload is missing or empty", async () => {
stubReadRecords([
{ kind: "message" }, // no payload
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message" } }, // no message
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: null } },
{
kind: "message",
payload: { chatId: "c1", trigger: "submit-message", message: "not-an-object" },
},
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(0);
});
it("skips non-object record data defensively", async () => {
const u1 = userMessage("u-1", "valid");
stubReadRecords([
42,
null,
"string-data",
{ kind: "message", payload: { chatId: "c1", trigger: "submit-message", message: u1 } },
]);
const result = await replaySessionInTail("sess");
expect(result).toHaveLength(1);
expect(result[0]!.message.id).toBe("u-1");
});
it("passes the afterEventId cursor through to readSessionStreamRecords", async () => {
const spy = stubReadRecords([]);
await replaySessionInTail("sess", { lastEventId: "evt-42" });
expect(spy).toHaveBeenCalledWith("sess", "in", { afterEventId: "evt-42" });
});
it("returns an empty list when the records endpoint returns no records", async () => {
stubReadRecords([]);
const result = await replaySessionInTail("sess");
expect(result).toEqual([]);
});
});
// ── Cursor scan (non-blocking records read, not an SSE drain) ──────────
function stubReadRecordsWithHeaders(
records: Array<{ data?: unknown; headers?: Array<[string, string]> }>
) {
const shaped = records.map((r, i) => ({
data: r.data ?? "",
id: `evt-${i + 1}`,
seqNum: i + 1,
headers: r.headers,
}));
const spy = vi.fn(async () => ({ records: shaped }));
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: spy,
} as never);
return spy;
}
describe("findLatestSessionInCursor", () => {
it("returns the LAST turn-complete's session-in-event-id", async () => {
const spy = stubReadRecordsWithHeaders([
{ data: { type: "text-delta", delta: "hi" } },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "3"],
],
},
{ data: { type: "text-delta", delta: "again" } },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "7"],
],
},
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBe(7);
// Non-blocking records read on `.out`, no SSE subscribe.
expect(spy).toHaveBeenCalledWith("sess", "out");
});
it("ignores other control subtypes and turn-completes without the header", async () => {
stubReadRecordsWithHeaders([
{
headers: [
["trigger-control", "upgrade-required"],
["session-in-event-id", "99"],
],
},
{ headers: [["trigger-control", "turn-complete"]] },
{
headers: [
["trigger-control", "turn-complete"],
["session-in-event-id", "4"],
],
},
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBe(4);
});
it("returns undefined when records carry no headers (older server)", async () => {
stubReadRecordsWithHeaders([
{ data: { type: "text-delta", delta: "hi" } },
{ data: { type: "finish" } },
]);
const cursor = await findLatestSessionInCursor("sess");
expect(cursor).toBeUndefined();
});
});
@@ -0,0 +1,275 @@
// Import the test entry point first so the resource catalog is installed.
import "../src/v3/test/index.js";
import type { UIMessageChunk } from "ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { apiClientManager } from "@trigger.dev/core/v3";
import { __replaySessionOutTailProductionPathForTests as replaySessionOutTail } from "../src/v3/ai.js";
// ── Helpers ────────────────────────────────────────────────────────────
/**
* Build the canonical chunk sequence the AI SDK emits for a single text
* turn from message `id`. Includes a trailing `finish` so the segment is
* marked closed (i.e. NOT subject to `cleanupAbortedParts`).
*/
function textTurn(id: string, text: string, role: "assistant" = "assistant"): UIMessageChunk[] {
return [
{ type: "start", messageId: id, messageMetadata: { role } } as UIMessageChunk,
{ type: "text-start", id: `${id}.t1` } as UIMessageChunk,
{ type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk,
{ type: "text-end", id: `${id}.t1` } as UIMessageChunk,
{ type: "finish" } as UIMessageChunk,
];
}
/**
* Stub `apiClientManager.clientOrThrow().readSessionStreamRecords` so the
* helper sees a `{ records: StreamRecord[] }` response. Each StreamRecord
* is `{ data, id, seqNum }` — `data` is the parsed chunk OBJECT (the wire
* writer puts chunks directly into the record envelope; the route
* forwards them as-is; the schema declares `data: z.unknown()`).
*
* Pass either a chunk OBJECT (used as `data` directly) or a string
* (used as `data` directly — for tests that need deliberately-malformed
* bodies; the consumer filters non-objects out).
*
* Captures the `afterEventId` argument for resume-from-cursor assertions.
*/
function stubReadRecordsWithChunks(chunks: unknown[]) {
const records = chunks.map((chunk, i) => ({
data: chunk,
id: `evt-${i + 1}`,
seqNum: i + 1,
}));
const readRecordsSpy = vi.fn(
async (_id: string, _io: "in" | "out", _options?: { afterEventId?: string }) => ({
records,
})
);
vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({
readSessionStreamRecords: readRecordsSpy,
} as never);
return readRecordsSpy;
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("replaySessionOutTail", () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
warnSpy.mockRestore();
});
it("returns [] for an empty session.out stream", async () => {
stubReadRecordsWithChunks([]);
const result = await replaySessionOutTail("empty-session");
expect(result).toEqual([]);
});
it("reduces a single text turn into one assistant UIMessage", async () => {
stubReadRecordsWithChunks(textTurn("a-1", "hello world"));
const result = await replaySessionOutTail("text-session");
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ id: "a-1", role: "assistant" });
const text = (result[0]!.parts as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
expect(text).toBe("hello world");
});
it("reduces multiple sequential turns into multiple UIMessages", async () => {
stubReadRecordsWithChunks([
...textTurn("a-1", "first"),
...textTurn("a-2", "second"),
...textTurn("a-3", "third"),
]);
const result = await replaySessionOutTail("multi-session");
expect(result).toHaveLength(3);
expect(result.map((m) => m.id)).toEqual(["a-1", "a-2", "a-3"]);
});
it("filters out `trigger:*` control chunks (turn-complete, etc.)", async () => {
stubReadRecordsWithChunks([
...textTurn("a-1", "hello"),
{ type: "trigger:turn-complete", lastEventId: "evt-1", lastEventTimestamp: 1 },
{ type: "trigger:upgrade-required" },
...textTurn("a-2", "second"),
]);
const result = await replaySessionOutTail("control-session");
// Two assistant messages reduced — the trigger:* records are dropped
// before reaching the reducer.
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toEqual(["a-1", "a-2"]);
});
it("never emits user-role messages (session.out is assistant-only)", async () => {
// session.out conceptually only carries assistant chunks (the user's
// messages live on session.in). Even if a user-role start somehow
// landed there, the reducer wouldn't surface a user message via this
// helper's contract.
stubReadRecordsWithChunks(textTurn("a-1", "ok"));
const result = await replaySessionOutTail("assistant-only");
expect(result.every((m) => m.role !== "user")).toBe(true);
});
it("passes `lastEventId` through as `afterEventId` to readSessionStreamRecords", async () => {
// The replay helper accepts `lastEventId` from the caller (matching
// the snapshot's persisted cursor name) and forwards it as
// `afterEventId` on the records endpoint — that's the field name on
// the new non-SSE route.
const readRecordsSpy = stubReadRecordsWithChunks(textTurn("a-1", "ok"));
await replaySessionOutTail("resume-session", { lastEventId: "evt-99" });
expect(readRecordsSpy).toHaveBeenCalledWith(
"resume-session",
"out",
expect.objectContaining({ afterEventId: "evt-99" })
);
});
it("uses the non-SSE records endpoint (drain-and-close, no long-poll)", async () => {
// Replay no longer subscribes to the SSE stream — that imposed a ~1s
// long-poll tax on every fresh chat boot. The new path hits
// `readSessionStreamRecords` (one synchronous GET that returns
// whatever's already in the stream) and returns immediately when
// empty. Lock the call site down so a regression to SSE shows up
// here.
const readRecordsSpy = stubReadRecordsWithChunks([]);
const result = await replaySessionOutTail("drain-session");
expect(readRecordsSpy).toHaveBeenCalledWith("drain-session", "out", expect.any(Object));
expect(result).toEqual([]);
});
it("strips orphaned in-flight tool parts from a partial trailing assistant", async () => {
// The runtime applies `cleanupAbortedParts` only on the trailing
// segment when its closure flag is `false` (no `finish` chunk
// received). The cleanup removes tool parts that never reached a
// terminal state — `input-streaming`, `output-pending`, etc. —
// because those represent partial in-flight work that won't resolve.
//
// Text parts with already-streamed content are preserved (the user
// already saw them), so we test the tool-part path specifically.
stubReadRecordsWithChunks([
...textTurn("a-1", "previous-turn-finished"),
// Trailing turn: starts a tool call but never resolves it.
{ type: "start", messageId: "a-2", messageMetadata: { role: "assistant" } } as UIMessageChunk,
{ type: "tool-input-start", toolCallId: "tc-cut", toolName: "search" } as UIMessageChunk,
{
type: "tool-input-delta",
toolCallId: "tc-cut",
inputTextDelta: '{"q":"x"}',
} as UIMessageChunk,
// No tool-input-end, no tool-call, no finish → orphaned.
]);
const result = await replaySessionOutTail("partial-tool-session");
// The closed turn survives.
expect(result.find((m) => m.id === "a-1")).toBeTruthy();
// Trailing message either gets dropped (cleanup empties it) or its
// orphaned tool part is stripped to a terminal state. Either way,
// no `tc-cut` part should be left in `input-streaming` state — that
// would represent a tool the next turn would re-process.
const trailing = result.find((m) => m.id === "a-2");
if (trailing) {
const orphanedToolPart = (
trailing.parts as Array<{ type: string; toolCallId?: string; state?: string }>
).find((p) => p.toolCallId === "tc-cut" && p.state === "input-streaming");
expect(orphanedToolPart).toBeUndefined();
}
});
it("drops a trailing message whose only parts are stripped by cleanup", async () => {
// Trailing turn whose ONLY content is an orphaned tool — after
// cleanup the message has no parts left, so the helper drops it
// entirely (it never reached the next turn's accumulator).
stubReadRecordsWithChunks([
...textTurn("a-1", "complete"),
{
type: "start",
messageId: "a-orphan",
messageMetadata: { role: "assistant" },
} as UIMessageChunk,
{ type: "tool-input-start", toolCallId: "tc-orph", toolName: "search" } as UIMessageChunk,
// No tool-input-end, no tool-call, no finish.
]);
const result = await replaySessionOutTail("dropped-trailing");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("preserves a complete trailing assistant (cleanup is a no-op)", async () => {
// Trailing turn that DID end with `finish` is closed — cleanupAbortedParts
// doesn't fire. Use this to lock down that closed segments survive
// unchanged.
stubReadRecordsWithChunks(textTurn("a-1", "fully-finished"));
const result = await replaySessionOutTail("closed-session");
expect(result).toHaveLength(1);
const text = (result[0]!.parts as Array<{ type: string; text?: string }>)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("");
expect(text).toBe("fully-finished");
});
it("skips records whose data is a string (the wire delivers objects)", async () => {
// The writer puts chunk objects directly into the record envelope;
// the route forwards them as-is. A string body is malformed — the
// consumer drops it defensively rather than JSON.parsing.
stubReadRecordsWithChunks(["not-an-object", ...textTurn("a-1", "survived")]);
const result = await replaySessionOutTail("garbage-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("skips records whose data is not an object", async () => {
// The consumer requires `chunk` to be a non-null object with a
// string `type` field. Records that arrive as primitives
// (number, null, string) are dropped silently.
stubReadRecordsWithChunks([42, null, "just-a-string", ...textTurn("a-1", "survived")]);
const result = await replaySessionOutTail("primitive-data-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("ignores chunks missing a `type` field", async () => {
stubReadRecordsWithChunks([{ foo: "bar" }, { type: 42 }, ...textTurn("a-1", "valid")]);
const result = await replaySessionOutTail("typeless-session");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("a-1");
});
it("recovers from a malformed segment by skipping it (logs a warn)", async () => {
// The reducer for one segment throws (e.g. invalid chunk sequence).
// The helper logs the warning and proceeds with the next segment —
// a single corrupt segment shouldn't sink the entire replay.
stubReadRecordsWithChunks([
// Malformed: text-end with no preceding text-start.
{
type: "start",
messageId: "bad-1",
messageMetadata: { role: "assistant" },
} as UIMessageChunk,
{ type: "text-end", id: "no-such-text" } as UIMessageChunk,
{ type: "finish" } as UIMessageChunk,
...textTurn("a-1", "after-bad"),
]);
const result = await replaySessionOutTail("recovery-session");
// The valid turn after the malformed one must still surface.
expect(result.find((m) => m.id === "a-1")).toBeTruthy();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { defineSkill, parseFrontmatter } from "../src/v3/skill.js";
describe("parseFrontmatter", () => {
it("parses name + description", () => {
const { frontmatter, body } = parseFrontmatter(
`---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n`
);
expect(frontmatter.name).toBe("pdf-processing");
expect(frontmatter.description).toBe("Extract text from PDFs.");
expect(body).toBe("# Body\n\nhello\n");
});
it("strips surrounding quotes", () => {
const { frontmatter } = parseFrontmatter(
`---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n`
);
expect(frontmatter.name).toBe("quoted-name");
expect(frontmatter.description).toBe("single quoted");
});
it("throws on missing frontmatter block", () => {
expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/);
});
it("throws on missing required name", () => {
expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow(
/missing required `name`/
);
});
it("throws on missing required description", () => {
expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow(
/missing required `description`/
);
});
});
describe("defineSkill.local()", () => {
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-")));
process.chdir(workdir);
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => {
const skillDir = path.join(workdir, ".trigger", "skills", "pdf");
await mkdir(skillDir, { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n`
);
const skill = defineSkill({ id: "pdf", path: "./skills/pdf" });
const resolved = await skill.local();
expect(resolved.id).toBe("pdf");
expect(resolved.version).toBe("local");
expect(resolved.labels).toEqual([]);
expect(resolved.frontmatter.name).toBe("pdf");
expect(resolved.frontmatter.description).toBe("Extract PDF text.");
expect(resolved.body).toContain("# PDF skill");
expect(resolved.body).toContain("Use scripts/extract.py");
expect(resolved.path).toBe(skillDir);
});
it("throws a useful error when SKILL.md is missing", async () => {
const skill = defineSkill({ id: "missing", path: "./skills/missing" });
await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/);
});
it("resolve() throws with a helpful Phase 1 message", async () => {
const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" });
await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s);
});
});
@@ -0,0 +1,221 @@
// Import the test harness FIRST so the resource catalog is installed
import { mockChatAgent } from "../src/v3/test/index.js";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises";
import { tmpdir } from "node:os";
import * as path from "node:path";
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider";
import { MockLanguageModelV3 } from "ai/test";
import { simulateReadableStream, streamText } from "ai";
import { buildSkillTools, chat } from "../src/v3/ai.js";
import { defineSkill } from "../src/v3/skill.js";
function userMessage(text: string, id?: string) {
return {
id: id ?? `u-${Math.random().toString(36).slice(2)}`,
role: "user" as const,
parts: [{ type: "text" as const, text }],
};
}
function textStream(text: string) {
const chunks: LanguageModelV3StreamPart[] = [
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: text },
{ type: "text-end", id: "t1" },
{
type: "finish",
finishReason: { unified: "stop", raw: "stop" },
usage: {
inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
outputTokens: { total: 10, text: 10, reasoning: undefined },
},
},
];
return simulateReadableStream({ chunks });
}
const originalCwd = process.cwd();
let workdir: string;
beforeEach(async () => {
workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-")));
process.chdir(workdir);
// Bundled skill layout
const skillDir = path.join(workdir, ".trigger", "skills", "demo");
await mkdir(path.join(skillDir, "scripts"), { recursive: true });
await mkdir(path.join(skillDir, "references"), { recursive: true });
await writeFile(
path.join(skillDir, "SKILL.md"),
`---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n`
);
const scriptPath = path.join(skillDir, "scripts", "hello.sh");
await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`);
await chmod(scriptPath, 0o755);
await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n");
});
afterEach(async () => {
process.chdir(originalCwd);
await rm(workdir, { recursive: true, force: true });
});
describe("chat.skills runtime integration", () => {
it("injects skills preamble into the system prompt", async () => {
let capturedSystem: string | undefined;
const model = new MockLanguageModelV3({
doStream: async (opts) => {
const system = opts.prompt.find((m) => m.role === "system");
capturedSystem = system ? JSON.stringify(system.content) : undefined;
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.system-prompt",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t1" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedSystem).toContain("Available skills");
expect(capturedSystem).toContain("demo: Demo skill for tests");
} finally {
await harness.close();
}
});
it("auto-wires loadSkill / readFile / bash tools", async () => {
let capturedToolNames: string[] = [];
const model = new MockLanguageModelV3({
doStream: async (opts) => {
capturedToolNames = (opts.tools ?? []).map((t) => t.name);
return { stream: textStream("ok") };
},
});
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const agent = chat.agent({
id: "skills-runtime.auto-tools",
onChatStart: async () => {
chat.skills.set([await skill.local()]);
},
run: async ({ messages, signal }) => {
return streamText({
model,
messages,
abortSignal: signal,
...chat.toStreamTextOptions(),
});
},
});
const harness = mockChatAgent(agent, { chatId: "t2" });
try {
await harness.sendMessage(userMessage("hi"));
await new Promise((r) => setTimeout(r, 20));
expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"]));
} finally {
await harness.close();
}
});
});
describe("buildSkillTools — direct execute", () => {
it("loadSkill returns body + path for a known skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const resolved = await skill.local();
const tools = buildSkillTools([resolved]);
const out = await (tools.loadSkill as any).execute({ name: "demo" });
expect(out.name).toBe("demo");
expect(out.body).toContain("# Demo");
expect(out.path).toBe(resolved.path);
});
it("loadSkill returns an error for an unknown skill", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.loadSkill as any).execute({ name: "missing" });
expect(out.error).toContain('Skill "missing" not found');
});
it("readFile reads a bundled reference", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "references/notes.txt",
});
expect(out.content).toBe("Reference note.\n");
});
it("readFile rejects path traversal", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "../../../../etc/passwd",
});
expect(out.error).toMatch(/escapes the skill directory/);
});
it("readFile rejects absolute paths", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.readFile as any).execute({
skill: "demo",
path: "/etc/passwd",
});
expect(out.error).toMatch(/must be relative/);
});
it("bash runs a bundled script and captures stdout", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "bash scripts/hello.sh world",
});
expect(out.exitCode).toBe(0);
expect(out.stdout).toContain("hi from world");
});
it("bash reports non-zero exit code", async () => {
const skill = defineSkill({ id: "demo", path: "./skills/demo" });
const tools = buildSkillTools([await skill.local()]);
const out = await (tools.bash as any).execute({
skill: "demo",
command: "exit 7",
});
expect(out.exitCode).toBe(7);
});
});
@@ -0,0 +1,545 @@
// The slim wire payload shape is the contract between the transport
// (`TriggerChatTransport.sendMessages` etc.) and the agent runtime. This
// test locks the shape down at the type and JSON-roundtrip level so a
// future change either holds the wire stable or breaks loudly.
//
// Plan F.1: verify `messages` is gone, `message`/`headStartMessages` are
// typed correctly. See plan section A.1.
import "../src/v3/test/index.js";
import type { UIMessage } from "ai";
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ChatInputChunk, ChatTaskWirePayload } from "../src/v3/ai-shared.js";
import { slimSubmitMessageForWire, upsertIncomingMessage } from "../src/v3/ai-shared.js";
describe("ChatTaskWirePayload (slim wire shape)", () => {
it("encodes and decodes a submit-message payload through JSON", () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hi" }],
};
const wire: ChatTaskWirePayload = {
message: userMsg,
chatId: "chat-1",
trigger: "submit-message",
metadata: { userId: "u-1" },
};
const encoded = JSON.stringify(wire);
const decoded = JSON.parse(encoded) as ChatTaskWirePayload;
expect(decoded).toEqual(wire);
expect(decoded.message).toEqual(userMsg);
expect(decoded.trigger).toBe("submit-message");
});
it("encodes and decodes a regenerate-message payload (no message body)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "regenerate-message",
metadata: undefined,
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("regenerate-message");
expect(decoded.message).toBeUndefined();
expect(decoded.headStartMessages).toBeUndefined();
});
it("encodes and decodes a handover-prepare payload with headStartMessages", () => {
const history: UIMessage[] = [
{
id: "u-1",
role: "user",
parts: [{ type: "text", text: "first" }],
},
{
id: "a-1",
role: "assistant",
parts: [{ type: "text", text: "ok" }],
},
];
const wire: ChatTaskWirePayload = {
headStartMessages: history,
chatId: "chat-1",
trigger: "handover-prepare",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.headStartMessages).toEqual(history);
expect(decoded.message).toBeUndefined();
});
it("encodes and decodes a preload payload (no message, no headStartMessages)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "preload",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("preload");
expect(decoded.message).toBeUndefined();
expect(decoded.headStartMessages).toBeUndefined();
});
it("encodes and decodes a close payload", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "close",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("close");
});
it("encodes and decodes an action payload (carries `action`, no message)", () => {
const wire: ChatTaskWirePayload = {
chatId: "chat-1",
trigger: "action",
action: { type: "undo" },
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.trigger).toBe("action");
expect(decoded.action).toEqual({ type: "undo" });
expect(decoded.message).toBeUndefined();
});
it("preserves continuation / previousRunId / sessionId across the wire", () => {
const wire: ChatTaskWirePayload = {
message: {
id: "u-2",
role: "user",
parts: [{ type: "text", text: "continued" }],
},
chatId: "chat-1",
trigger: "submit-message",
continuation: true,
previousRunId: "run_abc",
sessionId: "sess_xyz",
idleTimeoutInSeconds: 42,
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.continuation).toBe(true);
expect(decoded.previousRunId).toBe("run_abc");
expect(decoded.sessionId).toBe("sess_xyz");
expect(decoded.idleTimeoutInSeconds).toBe(42);
});
it("preserves a tool-approval-responded assistant message in `message`", () => {
// The HITL slim-wire path sends an assistant message with
// `state: "approval-responded"` tool parts in `message`, not the
// full chain. The agent merges by id.
const approvalMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-42",
state: "output-available",
input: { q: "x" },
output: { hits: 7 },
} as never,
],
};
const wire: ChatTaskWirePayload = {
message: approvalMsg,
chatId: "chat-1",
trigger: "submit-message",
};
const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload;
expect(decoded.message).toEqual(approvalMsg);
});
});
describe("upsertIncomingMessage", () => {
const userMsg = (id: string, text: string): UIMessage => ({
id,
role: "user",
parts: [{ type: "text", text }],
});
it("pushes a fresh user message and returns true", () => {
const stored: UIMessage[] = [userMsg("u-1", "first")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [userMsg("u-2", "second")],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
expect(stored[1]!.id).toBe("u-2");
});
it("no-ops when incoming id is already in stored (HITL continuation)", () => {
const head = {
id: "asst-1",
role: "assistant" as const,
parts: [
{ type: "tool-search", toolCallId: "tc-1", state: "input-available", input: {} } as never,
],
};
const stored: UIMessage[] = [userMsg("u-1", "hi"), head];
const slim = {
id: "asst-1",
role: "assistant" as const,
parts: [
{ type: "tool-search", toolCallId: "tc-1", state: "output-available", output: {} } as never,
],
};
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [slim],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(2);
// The original head is untouched — the runtime's per-turn merge
// overlays the resolution; the customer's stored array is just
// the pre-merge snapshot.
expect(stored[1]).toBe(head);
});
it("no-ops on regenerate-message trigger", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "regenerate-message",
incomingMessages: [userMsg("u-2", "ignored")],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("no-ops on action trigger", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "action",
incomingMessages: [],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("no-ops on empty incomingMessages", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [],
});
expect(mutated).toBe(false);
expect(stored).toHaveLength(1);
});
it("only inspects the last incoming message (slim wire ships at most one)", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [userMsg("ignored", "ignored"), userMsg("u-3", "new")],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
expect(stored[1]!.id).toBe("u-3");
});
it("pushes when newMsg has no id (no dedup possible)", () => {
const stored: UIMessage[] = [userMsg("u-1", "hi")];
const incoming = {
role: "user",
parts: [{ type: "text", text: "no id" }],
} as unknown as UIMessage;
const mutated = upsertIncomingMessage(stored, {
trigger: "submit-message",
incomingMessages: [incoming],
});
expect(mutated).toBe(true);
expect(stored).toHaveLength(2);
});
it("accepts the full hydrateMessages event without re-packaging", () => {
// Customers can pass the destructured event directly — the helper
// only reads `trigger` + `incomingMessages` but ignores any other
// fields the event happens to carry.
const stored: UIMessage[] = [];
const event = {
chatId: "chat-1",
turn: 0,
trigger: "submit-message" as const,
incomingMessages: [userMsg("u-1", "hi")],
previousMessages: [],
continuation: false,
};
const mutated = upsertIncomingMessage(stored, event);
expect(mutated).toBe(true);
expect(stored).toHaveLength(1);
});
});
describe("slimSubmitMessageForWire", () => {
it("passes user messages through unchanged", () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
};
expect(slimSubmitMessageForWire(userMsg)).toBe(userMsg);
});
it("passes assistant messages with no resolved tool parts through unchanged", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "thinking..." },
{
type: "tool-search",
toolCallId: "tc-1",
state: "input-available",
input: { q: "x" },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toBe(assistantMsg);
});
it("slims output-available HITL continuation to {type, toolCallId, state, output}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "let me search" },
{ type: "reasoning", text: "long reasoning blob..." } as never,
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-available",
input: { q: "very long query".repeat(1000) },
output: { hits: 7 },
} as never,
],
};
const slim = slimSubmitMessageForWire(assistantMsg);
expect(slim).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-available",
output: { hits: 7 },
},
],
});
// The slim drops `input` (server has it via hydrate/snapshot) — the
// wire is much smaller than the original.
expect(JSON.stringify(slim).length).toBeLessThan(JSON.stringify(assistantMsg).length / 50);
});
it("slims output-error to {type, toolCallId, state, errorText}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-error",
input: { q: "x" },
errorText: "boom",
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-search",
toolCallId: "tc-1",
state: "output-error",
errorText: "boom",
},
],
});
});
it("slims approval-responded to {type, toolCallId, state, approval}", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-delete",
toolCallId: "tc-1",
state: "approval-responded",
input: { path: "/critical" },
approval: { id: "appr_1", approved: true, reason: "looks fine" },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "tool-delete",
toolCallId: "tc-1",
state: "approval-responded",
approval: { id: "appr_1", approved: true, reason: "looks fine" },
},
],
});
});
it("slims dynamic-tool parts and preserves toolName", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: "dyn-search",
toolCallId: "tc-1",
state: "output-available",
input: { q: "x" },
output: { hits: 1 },
} as never,
],
};
expect(slimSubmitMessageForWire(assistantMsg)).toEqual({
id: "a-1",
role: "assistant",
parts: [
{
type: "dynamic-tool",
toolName: "dyn-search",
toolCallId: "tc-1",
state: "output-available",
output: { hits: 1 },
},
],
});
});
it("only slims the advanced tool parts when an assistant has mixed states", () => {
const assistantMsg: UIMessage = {
id: "a-1",
role: "assistant",
parts: [
{ type: "text", text: "thinking" },
{
type: "tool-search",
toolCallId: "tc-resolved",
state: "output-available",
input: { q: "x" },
output: { hits: 1 },
} as never,
{
type: "tool-askUser",
toolCallId: "tc-still-pending",
state: "input-available",
input: { q: "ok?" },
} as never,
],
};
const slim = slimSubmitMessageForWire(assistantMsg);
expect(slim?.parts).toHaveLength(1);
expect((slim!.parts[0] as any).toolCallId).toBe("tc-resolved");
});
it("handles undefined input", () => {
expect(slimSubmitMessageForWire(undefined)).toBeUndefined();
});
});
describe("ChatTaskWirePayload (compile-time shape)", () => {
it("does NOT have a `messages` array field (slim wire removed it)", () => {
// If a future edit reintroduces `messages: TMessage[]`, this assertion
// forces a compile error rather than letting the wire silently grow
// back.
type WirePayloadKeys = keyof ChatTaskWirePayload;
expectTypeOf<WirePayloadKeys>().not.toEqualTypeOf<
"messages" | Exclude<WirePayloadKeys, "messages">
>();
// Also confirm the absence at the value level — a payload literal
// with `messages` would be a TS error if uncommented:
//
// const bad: ChatTaskWirePayload = { messages: [], chatId: "x", trigger: "submit-message" };
//
// Leaving as a comment for clarity; the type assertion above is the
// load-bearing check.
});
it("has `message?: UIMessage` (singular, optional)", () => {
expectTypeOf<ChatTaskWirePayload["message"]>().toEqualTypeOf<UIMessage | undefined>();
});
it("has `headStartMessages?: UIMessage[]` (escape hatch)", () => {
expectTypeOf<ChatTaskWirePayload["headStartMessages"]>().toEqualTypeOf<
UIMessage[] | undefined
>();
});
it("requires `chatId: string` and `trigger: <one of>`", () => {
expectTypeOf<ChatTaskWirePayload["chatId"]>().toEqualTypeOf<string>();
expectTypeOf<ChatTaskWirePayload["trigger"]>().toEqualTypeOf<
"submit-message" | "regenerate-message" | "preload" | "close" | "action" | "handover-prepare"
>();
});
});
describe("ChatInputChunk envelope", () => {
it('wraps a wire payload in `kind: "message"` shape', () => {
const userMsg: UIMessage = {
id: "u-1",
role: "user",
parts: [{ type: "text", text: "hello" }],
};
const chunk: ChatInputChunk = {
kind: "message",
payload: {
message: userMsg,
chatId: "chat-1",
trigger: "submit-message",
},
};
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("message");
if (decoded.kind === "message") {
expect(decoded.payload.message).toEqual(userMsg);
}
});
it('supports `kind: "stop"` records (no payload)', () => {
const chunk: ChatInputChunk = { kind: "stop", message: "user-canceled" };
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("stop");
if (decoded.kind === "stop") {
expect(decoded.message).toBe("user-canceled");
}
});
it('supports `kind: "handover"` records (with partialAssistantMessage)', () => {
const chunk: ChatInputChunk = {
kind: "handover",
partialAssistantMessage: [
{ role: "assistant", content: [{ type: "text", text: "partial" }] },
],
messageId: "a-1",
isFinal: false,
};
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("handover");
});
it('supports `kind: "handover-skip"` records', () => {
const chunk: ChatInputChunk = { kind: "handover-skip" };
const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk;
expect(decoded.kind).toBe("handover-skip");
});
});