Files
2026-07-13 13:32:57 +08:00

276 lines
12 KiB
TypeScript

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