chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user