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

331 lines
11 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { UIMessage } from "ai";
import {
TriggerChatTransport,
type ChatTransportEvent,
type TriggerChatTransportOptions,
} from "../src/v3/chat.js";
// ── Helpers ────────────────────────────────────────────────────────────
function user(text: string, id: string): UIMessage {
return { id, role: "user", parts: [{ type: "text", text }] };
}
function jsonOk(): Response {
return new Response("{}", { status: 200 });
}
/** Build a `text/event-stream` Response from raw SSE text (v1 wire). */
function sseResponse(frames: string): Response {
return new Response(frames, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
/** SSE body: one data chunk then a legacy turn-complete control chunk. */
const SSE_ONE_TURN = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"hello"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
async function readAll(stream: ReadableStream<unknown>): Promise<unknown[]> {
const out: unknown[] = [];
const reader = stream.getReader();
while (true) {
const next = await reader.read();
if (next.done) return out;
out.push(next.value);
}
}
function makeTransport(overrides: Partial<TriggerChatTransportOptions> = {}) {
const events: ChatTransportEvent[] = [];
const transport = new TriggerChatTransport({
task: "test-task",
accessToken: async () => "tok_test",
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: false } },
onEvent: (event) => events.push(event),
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(SSE_ONE_TURN),
...overrides,
});
return { transport, events };
}
// ── Tests ──────────────────────────────────────────────────────────────
describe("transport send events", () => {
it("emits message-sent for a successful submit and the full stream lifecycle", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
const sent = events[0] as Extract<ChatTransportEvent, { type: "message-sent" }>;
expect(sent.chatId).toBe("c1");
expect(sent.messageId).toBe("u-1");
expect(sent.source).toBe("submit-message");
expect(sent.durationMs).toBeGreaterThanOrEqual(0);
expect(sent.timestamp).toBeGreaterThan(0);
expect(sent.partId).toMatch(/[0-9a-f-]{36}/);
expect(sent.bodyBytes).toBeGreaterThan(0);
const connected = events[1] as Extract<ChatTransportEvent, { type: "stream-connected" }>;
expect(connected.resumed).toBe(false);
expect(connected.messageId).toBe("u-1");
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("text-delta");
expect(firstChunk.lastEventId).toBe("1");
expect(firstChunk.messageId).toBe("u-1");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
const turnCompleted = events[3] as Extract<ChatTransportEvent, { type: "turn-completed" }>;
expect(turnCompleted.messageId).toBe("u-1");
expect(turnCompleted.sinceSendMs).toBeGreaterThanOrEqual(0);
expect(turnCompleted.lastEventId).toBe("2");
});
it("emits message-send-failed with the HTTP status when the append fails", async () => {
const { transport, events } = makeTransport({
fetch: async () => new Response("too large", { status: 413 }),
});
await expect(
transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
})
).rejects.toThrow();
expect(events).toHaveLength(1);
const failed = events[0] as Extract<ChatTransportEvent, { type: "message-send-failed" }>;
expect(failed.type).toBe("message-send-failed");
expect(failed.source).toBe("submit-message");
expect(failed.messageId).toBe("u-1");
expect(failed.status).toBe(413);
expect(failed.error).toBeInstanceOf(Error);
});
it("emits steer events from sendPendingMessage without changing its boolean result", async () => {
const { transport, events } = makeTransport();
const ok = await transport.sendPendingMessage("c1", user("steer", "u-2"));
expect(ok).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "steer", messageId: "u-2" });
const failing = makeTransport({ fetch: async () => new Response("nope", { status: 500 }) });
const notOk = await failing.transport.sendPendingMessage("c1", user("steer", "u-3"));
expect(notOk).toBe(false);
expect(failing.events[0]).toMatchObject({
type: "message-send-failed",
source: "steer",
status: 500,
});
});
it("emits action and stop send events", async () => {
const { transport, events } = makeTransport();
const stream = await transport.sendAction("c1", { type: "undo" });
await readAll(stream);
expect(events[0]).toMatchObject({ type: "message-sent", source: "action" });
events.length = 0;
const stopped = await transport.stopGeneration("c1");
expect(stopped).toBe(true);
expect(events[0]).toMatchObject({ type: "message-sent", source: "stop" });
});
it("swallows exceptions thrown by the onEvent callback", async () => {
const { transport } = makeTransport({
onEvent: () => {
throw new Error("observer exploded");
},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
const chunks = await readAll(stream);
expect(chunks.length).toBeGreaterThan(0);
});
});
describe("transport stream events", () => {
it("marks reconnectToStream subscriptions as resumed", async () => {
const { transport, events } = makeTransport({
sessions: {
c1: { publicAccessToken: "tok_test", isStreaming: true, lastEventId: "1" },
},
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
expect(stream).not.toBeNull();
await readAll(stream!);
const connected = events.find((e) => e.type === "stream-connected") as Extract<
ChatTransportEvent,
{ type: "stream-connected" }
>;
expect(connected.resumed).toBe(true);
expect(events.some((e) => e.type === "turn-completed")).toBe(true);
});
it("re-arms first-chunk per turn on a watch-mode stream", async () => {
const TWO_TURNS = [
`id: 1`,
`data: {"type":"text-delta","id":"t1","delta":"turn one"}`,
``,
`id: 2`,
`data: {"type":"trigger:turn-complete"}`,
``,
`id: 3`,
`data: {"type":"text-delta","id":"t2","delta":"turn two"}`,
``,
`id: 4`,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const { transport, events } = makeTransport({
watch: true,
sessions: { c1: { publicAccessToken: "tok_test", isStreaming: true } },
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : sseResponse(TWO_TURNS),
});
const stream = await transport.reconnectToStream({ chatId: "c1" });
await readAll(stream!);
expect(events.filter((e) => e.type === "first-chunk")).toHaveLength(2);
expect(events.filter((e) => e.type === "turn-completed")).toHaveLength(2);
});
it("emits the full lifecycle on the headStart first-turn path", async () => {
const handoverSse = [
`data: {"type":"start","messageId":"a-1"}`,
``,
`data: {"type":"text-delta","id":"t1","delta":"warm"}`,
``,
`data: {"type":"trigger:turn-complete"}`,
``,
``,
].join("\n");
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(handoverSse, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({
headStart: "/api/chat",
sessions: {},
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await readAll(stream);
const types = events.map((e) => e.type);
expect(types).toEqual(["message-sent", "stream-connected", "first-chunk", "turn-completed"]);
expect(events[0]).toMatchObject({ source: "head-start", messageId: "u-hs" });
const firstChunk = events[2] as Extract<ChatTransportEvent, { type: "first-chunk" }>;
expect(firstChunk.chunkType).toBe("start");
expect(firstChunk.messageId).toBe("u-hs");
expect(firstChunk.sinceSendMs).toBeGreaterThanOrEqual(0);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the headStart response body fails mid-read", async () => {
const encoder = new TextEncoder();
const failingBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: {"type":"text-delta","id":"t1","delta":"w"}\n\n`));
controller.error(new Error("network drop"));
},
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(failingBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"X-Trigger-Chat-Access-Token": "tok_handover",
},
})) as typeof fetch;
try {
const { transport, events } = makeTransport({ headStart: "/api/chat", sessions: {} });
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c-hs-err",
messageId: undefined,
messages: [user("hi", "u-hs")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow("network drop");
const streamError = events.find((e) => e.type === "stream-error");
expect(streamError).toBeDefined();
expect(events.some((e) => e.type === "turn-completed")).toBe(false);
} finally {
globalThis.fetch = originalFetch;
}
});
it("emits stream-error when the output stream fails unrecoverably", async () => {
const { transport, events } = makeTransport({
fetch: async (_url, _init, ctx) =>
ctx.endpoint === "in" ? jsonOk() : new Response("gone", { status: 400 }),
});
const stream = await transport.sendMessages({
trigger: "submit-message",
chatId: "c1",
messageId: undefined,
messages: [user("hi", "u-1")],
abortSignal: undefined,
});
await expect(readAll(stream)).rejects.toThrow();
expect(events.some((e) => e.type === "stream-error")).toBe(true);
expect(events.some((e) => e.type === "stream-connected")).toBe(false);
});
});