Files
triggerdotdev--trigger.dev/packages/trigger-sdk/test/chatHandover.test.ts
T
2026-07-13 13:32:57 +08:00

636 lines
22 KiB
TypeScript

// 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();
}
});
});