226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
// 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();
|
|
});
|
|
});
|