// Import the test entry point first so the resource catalog is installed — // not strictly required for these helper-level tests, but keeps parity with // the rest of the test suite and removes a potential foot-gun if a future // edit introduces a chat.agent({...}) at module scope. import "../src/v3/test/index.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { apiClientManager } from "@trigger.dev/core/v3"; import { __readChatSnapshotProductionPathForTests as readChatSnapshot, __writeChatSnapshotProductionPathForTests as writeChatSnapshot, type ChatSnapshotV1, } from "../src/v3/ai.js"; // ── Helpers ──────────────────────────────────────────────────────────── /** * Build a minimal ChatSnapshotV1 with `count` user messages. Used as the * production-path test payload — `messages` is the only field the runtime * inspects beyond `version`. */ function buildSnapshot(count = 1): ChatSnapshotV1 { return { version: 1, savedAt: 1_000_000, messages: Array.from({ length: count }, (_, i) => ({ id: `m${i}`, role: "user" as const, parts: [{ type: "text" as const, text: `hello ${i}` }], })), lastOutEventId: "evt-42", }; } /** * Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API * client whose `getChatSnapshotUrl` / `createChatSnapshotUploadUrl` resolve * with the presigned URLs the test wants. Returns spies for assertion. */ function stubApiClient(opts: { getChatSnapshotUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>; createChatSnapshotUploadUrl?: (sessionId: string) => Promise<{ presignedUrl: string }>; }) { const getChatSnapshotUrl = vi.fn( opts.getChatSnapshotUrl ?? (async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/get" })) ); const createChatSnapshotUploadUrl = vi.fn( opts.createChatSnapshotUploadUrl ?? (async (_sessionId: string) => ({ presignedUrl: "https://example.invalid/put" })) ); const fakeClient = { getChatSnapshotUrl, createChatSnapshotUploadUrl, }; vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue(fakeClient as never); return { getChatSnapshotUrl, createChatSnapshotUploadUrl }; } /** * Stub global `fetch` so the helpers see whatever Response (or throw) the * test wants. Returns a spy keyed on the URL passed. */ function stubFetch(impl: (url: string, init?: RequestInit) => Promise | Response) { const spy = vi.fn(impl); vi.stubGlobal("fetch", spy); return spy; } // ── Tests ────────────────────────────────────────────────────────────── describe("chat snapshot helpers", () => { // Suppress the runtime's `logger.warn` calls — they pollute output but // don't change test outcomes. Restored in afterEach. let warnSpy: ReturnType; beforeEach(() => { warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); warnSpy.mockRestore(); }); describe("readChatSnapshot", () => { it("returns the snapshot on a successful GET", async () => { const { getChatSnapshotUrl } = stubApiClient({}); const snapshot = buildSnapshot(2); stubFetch( async () => new Response(JSON.stringify(snapshot), { status: 200, headers: { "content-type": "application/json" }, }) ); const result = await readChatSnapshot("session-1"); expect(getChatSnapshotUrl).toHaveBeenCalledWith("session-1"); expect(result).toMatchObject({ version: 1, messages: snapshot.messages, lastOutEventId: "evt-42", }); }); it("returns undefined on 404 (fresh session, no snapshot yet)", async () => { stubApiClient({}); stubFetch(async () => new Response("Not Found", { status: 404 })); const result = await readChatSnapshot("missing-session"); expect(result).toBeUndefined(); }); it("returns undefined on non-404 non-OK (e.g. 500)", async () => { stubApiClient({}); stubFetch(async () => new Response("Internal Error", { status: 500 })); const result = await readChatSnapshot("flaky-session"); expect(result).toBeUndefined(); }); it("returns undefined when the response body is malformed JSON", async () => { stubApiClient({}); stubFetch( async () => new Response("not-json-{[", { status: 200, headers: { "content-type": "application/json" }, }) ); const result = await readChatSnapshot("malformed-session"); expect(result).toBeUndefined(); }); it("returns undefined on version mismatch (forward-compat)", async () => { stubApiClient({}); // Future format the current runtime can't decode — runtime ignores it. const futureSnapshot = { version: 99, savedAt: Date.now(), messages: [], }; stubFetch( async () => new Response(JSON.stringify(futureSnapshot), { status: 200, headers: { "content-type": "application/json" }, }) ); const result = await readChatSnapshot("v99-session"); expect(result).toBeUndefined(); }); it("returns undefined when `messages` field is missing or wrong type", async () => { stubApiClient({}); stubFetch( async () => new Response(JSON.stringify({ version: 1, savedAt: 1, messages: "not-an-array" }), { status: 200, }) ); const result = await readChatSnapshot("bad-shape-session"); expect(result).toBeUndefined(); }); it("returns undefined when fetch throws (network error)", async () => { stubApiClient({}); stubFetch(async () => { throw new Error("ECONNREFUSED"); }); const result = await readChatSnapshot("offline-session"); expect(result).toBeUndefined(); }); it("returns undefined when presign call fails", async () => { stubApiClient({ getChatSnapshotUrl: async () => { throw new Error("presign denied"); }, }); // No fetch should fire — presign failed. const fetchSpy = stubFetch(async () => new Response("nope", { status: 500 })); const result = await readChatSnapshot("denied-session"); expect(result).toBeUndefined(); expect(fetchSpy).not.toHaveBeenCalled(); }); it("returns undefined when the response is not an object", async () => { stubApiClient({}); stubFetch(async () => new Response(JSON.stringify("just-a-string"), { status: 200 })); const result = await readChatSnapshot("string-response"); expect(result).toBeUndefined(); }); }); describe("writeChatSnapshot", () => { it("PUTs the snapshot JSON to the presigned URL", async () => { const { createChatSnapshotUploadUrl } = stubApiClient({}); const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); const snapshot = buildSnapshot(3); await writeChatSnapshot("session-2", snapshot); expect(createChatSnapshotUploadUrl).toHaveBeenCalledWith("session-2"); expect(fetchSpy).toHaveBeenCalledOnce(); const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe("https://example.invalid/put"); expect((init as RequestInit).method).toBe("PUT"); expect((init as RequestInit).headers).toMatchObject({ "content-type": "application/json", }); // Body is the JSON-stringified snapshot — round-trip to confirm. const sentBody = JSON.parse((init as RequestInit).body as string); expect(sentBody).toEqual(snapshot); }); it("returns without throwing on a non-OK PUT response (warns)", async () => { stubApiClient({}); stubFetch(async () => new Response("forbidden", { status: 403 })); await expect( writeChatSnapshot("forbidden-session", buildSnapshot()) ).resolves.toBeUndefined(); }); it("returns without throwing on a fetch network error (warns)", async () => { stubApiClient({}); stubFetch(async () => { throw new Error("ETIMEDOUT"); }); await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined(); }); it("returns without throwing when presign fails (warns)", async () => { stubApiClient({ createChatSnapshotUploadUrl: async () => { throw new Error("presign denied"); }, }); const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined(); // Presign failed → no PUT attempted. expect(fetchSpy).not.toHaveBeenCalled(); }); it("addresses reads and writes by the same sessionId", async () => { // Round-trip check: both presign methods receive the same sessionId. // The canonical key (`sessions/{id}/snapshot.json`) lives server-side // now, so the SDK has no key string to compare — sessionId equality // is the SDK-visible invariant. const { getChatSnapshotUrl } = stubApiClient({ getChatSnapshotUrl: async () => ({ presignedUrl: "https://example.invalid/get" }), }); stubFetch(async () => new Response(null, { status: 404 })); await readChatSnapshot("round-trip-session"); const [readArg] = getChatSnapshotUrl.mock.calls[0]!; const { createChatSnapshotUploadUrl } = stubApiClient({ createChatSnapshotUploadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }), }); stubFetch(async () => new Response(null, { status: 200 })); await writeChatSnapshot("round-trip-session", buildSnapshot()); const [writeArg] = createChatSnapshotUploadUrl.mock.calls[0]!; expect(readArg).toBe(writeArg); expect(readArg).toBe("round-trip-session"); }); }); });