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

469 lines
17 KiB
TypeScript

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