@convex-dev/agent
Version:
A agent component for Convex.
1,129 lines (990 loc) • 35.7 kB
text/typescript
import { beforeEach, describe, expect, test } from "vitest";
import { createThread } from "./index.js";
import type { GenericSchema, SchemaDefinition } from "convex/server";
import { streamText } from "ai";
import { components, initConvexTest } from "./setup.test.js";
import { mockModel } from "./mockModel.js";
import {
compressUIMessageChunks,
DeltaStreamer,
mergeTransforms,
} from "./streaming.js";
import { getParts, deriveUIMessagesFromDeltas } from "../deltas.js";
import type { TestConvex } from "convex-test";
import type { StreamDelta, StreamMessage } from "../validators.js";
import { dedupeMessages } from "../react/useUIMessages.js";
const defaultTestOptions = {
throttleMs: 0,
abortSignal: undefined,
compress: null,
onAsyncAbort: async (_reason: string) => {
// In integration tests, async aborts can happen when the stream
// finishes before a pending delta write completes. This is expected.
},
};
const testMetadata = {
order: 0,
stepOrder: 0,
agentName: "test agent",
model: "test model",
provider: "test provider",
providerOptions: {},
format: "UIMessageChunk" as const,
};
// ============================================================================
// HTTP Streaming Initiation
// ============================================================================
describe("HTTP Streaming Initiation", () => {
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
let threadId: string;
beforeEach(async () => {
t = initConvexTest();
await t.run(async (ctx) => {
threadId = await createThread(ctx, components.agent, {});
});
});
test("DeltaStreamer creates a stream on first addParts call", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
expect(streamer.streamId).toBeUndefined();
await streamer.addParts([{ type: "start" }]);
expect(streamer.streamId).toBeDefined();
});
});
test("DeltaStreamer.getStreamId creates the stream lazily", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
expect(streamer.streamId).toBeUndefined();
const streamId = await streamer.getStreamId();
expect(streamId).toBeDefined();
expect(streamer.streamId).toBe(streamId);
});
});
test("DeltaStreamer.getStreamId returns the same ID on repeated calls", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const id1 = await streamer.getStreamId();
const id2 = await streamer.getStreamId();
expect(id1).toBe(id2);
});
});
test("Stream is created with streaming state", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
await streamer.getStreamId();
const streams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming"],
});
expect(streams).toHaveLength(1);
expect(streams[0].status).toBe("streaming");
expect(streams[0].agentName).toBe("test agent");
expect(streams[0].model).toBe("test model");
});
});
test("consumeStream processes full AI SDK stream to deltas", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const result = streamText({
model: mockModel({
content: [{ type: "text", text: "Hello world" }],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
// Ensure the AI SDK result is also fully consumed
await result.consumeStream();
expect(streamer.streamId).toBeDefined();
// Verify deltas were saved
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: 0, streamId: streamer.streamId! }],
});
expect(deltas.length).toBeGreaterThan(0);
// Verify we can reconstruct the text from deltas
const { parts } = getParts(deltas);
const textParts = parts.filter((p: any) => p.type === "text-delta");
expect(textParts.length).toBeGreaterThan(0);
});
});
test("consumeStream transitions stream to finished state", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const result = streamText({
model: mockModel({
content: [{ type: "text", text: "Done" }],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
// Stream should now be finished
const streamingStreams = await ctx.runQuery(
components.agent.streams.list,
{ threadId, statuses: ["streaming"] },
);
expect(streamingStreams).toHaveLength(0);
const finishedStreams = await ctx.runQuery(
components.agent.streams.list,
{ threadId, statuses: ["finished"] },
);
expect(finishedStreams).toHaveLength(1);
expect(finishedStreams[0].status).toBe("finished");
});
});
test("markFinishedExternally prevents consumeStream from calling finish", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
await streamer.getStreamId();
streamer.markFinishedExternally();
const result = streamText({
model: mockModel({
content: [{ type: "text", text: "Hello" }],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
// Stream should still be in streaming state since finish was skipped
const streamingStreams = await ctx.runQuery(
components.agent.streams.list,
{ threadId, statuses: ["streaming"] },
);
expect(streamingStreams).toHaveLength(1);
});
});
});
// ============================================================================
// Stream Exclusion Logic
// ============================================================================
describe("Stream Exclusion Logic", () => {
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
let threadId: string;
beforeEach(async () => {
t = initConvexTest();
await t.run(async (ctx) => {
threadId = await createThread(ctx, components.agent, {});
});
});
test("list defaults to only streaming status", async () => {
await t.run(async (ctx) => {
// Create a stream and finish it
const streamer1 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 0 },
);
const r1 = streamText({
model: mockModel({ content: [{ type: "text", text: "Finished" }] }),
prompt: "Test",
});
await streamer1.consumeStream(r1.toUIMessageStream());
// Create a still-streaming stream
const streamer2 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 1 },
);
await streamer2.getStreamId();
await streamer2.addParts([{ type: "start" }]);
// Default list: only streaming
const defaultStreams = await ctx.runQuery(components.agent.streams.list, {
threadId,
});
expect(defaultStreams).toHaveLength(1);
expect(defaultStreams[0].status).toBe("streaming");
expect(defaultStreams[0].order).toBe(1);
});
});
test("list with includeStatuses filters correctly", async () => {
await t.run(async (ctx) => {
// Create and finish a stream
const finishedStreamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 0 },
);
const r = streamText({
model: mockModel({ content: [{ type: "text", text: "Done" }] }),
prompt: "Test",
});
await finishedStreamer.consumeStream(r.toUIMessageStream());
// Create and abort a stream
const abortedStreamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 1 },
);
await abortedStreamer.getStreamId();
await abortedStreamer.fail("test abort");
// Create a still-streaming stream
const activeStreamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 2 },
);
await activeStreamer.getStreamId();
// Query for all statuses
const allStreams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming", "finished", "aborted"],
});
expect(allStreams).toHaveLength(3);
// Query for only finished
const finishedStreams = await ctx.runQuery(
components.agent.streams.list,
{ threadId, statuses: ["finished"] },
);
expect(finishedStreams).toHaveLength(1);
expect(finishedStreams[0].status).toBe("finished");
// Query for only aborted
const abortedStreams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["aborted"],
});
expect(abortedStreams).toHaveLength(1);
expect(abortedStreams[0].status).toBe("aborted");
// Query for streaming + aborted
const streamingAndAborted = await ctx.runQuery(
components.agent.streams.list,
{ threadId, statuses: ["streaming", "aborted"] },
);
expect(streamingAndAborted).toHaveLength(2);
});
});
test("startOrder filters out streams with lower order", async () => {
await t.run(async (ctx) => {
// Create streams at different orders
for (const order of [0, 1, 2, 3]) {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order },
);
await streamer.getStreamId();
}
// startOrder=2 should only return streams with order >= 2
const filtered = await ctx.runQuery(components.agent.streams.list, {
threadId,
startOrder: 2,
statuses: ["streaming"],
});
expect(filtered).toHaveLength(2);
expect(filtered.every((s) => s.order >= 2)).toBe(true);
});
});
test("streams from different threads are isolated", async () => {
let threadId2: string;
await t.run(async (ctx) => {
threadId2 = await createThread(ctx, components.agent, {});
// Create a stream in thread 1
const s1 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 0 },
);
await s1.getStreamId();
// Create a stream in thread 2
const s2 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId: threadId2, order: 0 },
);
await s2.getStreamId();
// Each thread should only see its own streams
const t1Streams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming"],
});
expect(t1Streams).toHaveLength(1);
const t2Streams = await ctx.runQuery(components.agent.streams.list, {
threadId: threadId2,
statuses: ["streaming"],
});
expect(t2Streams).toHaveLength(1);
expect(t1Streams[0].streamId).not.toBe(t2Streams[0].streamId);
});
});
test("dedupeMessages prefers finalized over streaming over pending", () => {
type M = {
order: number;
stepOrder: number;
status: "pending" | "success" | "failed" | "streaming";
};
const messages: M[] = [
{ order: 1, stepOrder: 0, status: "pending" },
{ order: 2, stepOrder: 0, status: "success" },
{ order: 3, stepOrder: 0, status: "pending" },
];
const streamMessages: M[] = [
{ order: 1, stepOrder: 0, status: "streaming" },
{ order: 2, stepOrder: 0, status: "streaming" },
{ order: 3, stepOrder: 0, status: "success" },
];
const result = dedupeMessages(messages, streamMessages);
expect(result).toHaveLength(3);
// pending replaced by streaming
expect(result[0].status).toBe("streaming");
// success kept over streaming
expect(result[1].status).toBe("success");
// pending replaced by success
expect(result[2].status).toBe("success");
});
});
// ============================================================================
// Delta Stream Consumption
// ============================================================================
describe("Delta Stream Consumption", () => {
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
let threadId: string;
beforeEach(async () => {
t = initConvexTest();
await t.run(async (ctx) => {
threadId = await createThread(ctx, components.agent, {});
});
});
test("cursor-based incremental delta fetching", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const result = streamText({
model: mockModel({
content: [{ type: "text", text: "One Two Three Four" }],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
const streamId = streamer.streamId!;
// Fetch all deltas from start
const allDeltas = await ctx.runQuery(
components.agent.streams.listDeltas,
{ threadId, cursors: [{ cursor: 0, streamId }] },
);
expect(allDeltas.length).toBeGreaterThan(0);
const { parts: allParts, cursor: endCursor } = getParts(allDeltas);
// Fetch from midpoint cursor - should only get remaining deltas
const midCursor = Math.floor(endCursor / 2);
const laterDeltas = await ctx.runQuery(
components.agent.streams.listDeltas,
{ threadId, cursors: [{ cursor: midCursor, streamId }] },
);
const { parts: laterParts } = getParts(laterDeltas, midCursor);
// Later parts should be a subset of all parts
expect(laterParts.length).toBeLessThanOrEqual(allParts.length);
// Fetching from the end cursor should yield nothing
const noDeltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: endCursor, streamId }],
});
expect(noDeltas).toHaveLength(0);
});
});
test("multi-stream delta fetching with separate cursors", async () => {
await t.run(async (ctx) => {
// Create two streams with different content
const streamer1 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 0 },
);
const r1 = streamText({
model: mockModel({ content: [{ type: "text", text: "Stream One" }] }),
prompt: "Test 1",
});
await streamer1.consumeStream(r1.toUIMessageStream());
const streamer2 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 1 },
);
const r2 = streamText({
model: mockModel({ content: [{ type: "text", text: "Stream Two" }] }),
prompt: "Test 2",
});
await streamer2.consumeStream(r2.toUIMessageStream());
const id1 = streamer1.streamId!;
const id2 = streamer2.streamId!;
// Fetch deltas for both streams simultaneously
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [
{ cursor: 0, streamId: id1 },
{ cursor: 0, streamId: id2 },
],
});
// Should have deltas for both streams
const s1Deltas = deltas.filter((d) => d.streamId === id1);
const s2Deltas = deltas.filter((d) => d.streamId === id2);
expect(s1Deltas.length).toBeGreaterThan(0);
expect(s2Deltas.length).toBeGreaterThan(0);
});
});
test("deriveUIMessagesFromDeltas reconstructs messages from UIMessageChunk format", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const result = streamText({
model: mockModel({
content: [{ type: "text", text: "Hello from deltas" }],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
const streamId = streamer.streamId!;
// Fetch stream messages and deltas
const streams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["finished"],
});
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: 0, streamId }],
});
// Derive UI messages
const uiMessages = await deriveUIMessagesFromDeltas(
threadId,
streams,
deltas,
);
expect(uiMessages).toHaveLength(1);
expect(uiMessages[0].role).toBe("assistant");
expect(uiMessages[0].text).toContain("Hello");
expect(uiMessages[0].text).toContain("from");
expect(uiMessages[0].text).toContain("deltas");
});
});
test("compression merges consecutive text deltas", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{
throttleMs: 1000,
abortSignal: undefined,
compress: compressUIMessageChunks,
onAsyncAbort: async () => {
throw new Error("async abort");
},
},
{ ...testMetadata, threadId },
);
const result = streamText({
model: mockModel({
content: [
{ type: "text", text: "A B C" },
{ type: "reasoning", text: "X Y Z" },
],
}),
prompt: "Test",
});
await streamer.consumeStream(result.toUIMessageStream());
const streamId = streamer.streamId!;
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: 0, streamId }],
});
const { parts } = getParts(deltas);
// Compressed: all text-deltas for one text section should be merged
const textDeltas = parts.filter((p: any) => p.type === "text-delta");
// With compression and throttleMs=1000, text deltas should be merged
expect(textDeltas.length).toBeLessThanOrEqual(1);
if (textDeltas.length === 1) {
expect((textDeltas[0] as { delta: string }).delta).toBe("A B C");
}
// Reasoning deltas should also be merged
const reasoningDeltas = parts.filter(
(p: any) => p.type === "reasoning-delta",
);
expect(reasoningDeltas.length).toBeLessThanOrEqual(1);
});
});
test("getParts validates delta continuity", () => {
const streamId = "test-stream";
// Normal continuous deltas
const deltas: StreamDelta[] = [
{ streamId, start: 0, end: 3, parts: [{ type: "text-delta" }] },
{ streamId, start: 3, end: 6, parts: [{ type: "text-delta" }] },
{ streamId, start: 6, end: 9, parts: [{ type: "text-delta" }] },
];
const { parts, cursor } = getParts(deltas);
expect(parts).toHaveLength(3);
expect(cursor).toBe(9);
});
test("getParts handles gap in deltas gracefully", () => {
const streamId = "test-stream";
// Deltas with a gap (missing 3-6)
const deltas: StreamDelta[] = [
{ streamId, start: 0, end: 3, parts: [{ type: "a" }] },
{ streamId, start: 6, end: 9, parts: [{ type: "b" }] },
];
const { parts, cursor } = getParts(deltas);
// Should stop at the gap
expect(parts).toHaveLength(1);
expect(cursor).toBe(3);
});
test("getParts skips already-consumed deltas", () => {
const streamId = "test-stream";
const deltas: StreamDelta[] = [
{ streamId, start: 0, end: 3, parts: [{ type: "old" }] },
{ streamId, start: 3, end: 6, parts: [{ type: "new" }] },
];
// Start from cursor=3 to skip first delta
const { parts, cursor } = getParts(deltas, 3);
expect(parts).toHaveLength(1);
expect((parts[0] as { type: string }).type).toBe("new");
expect(cursor).toBe(6);
});
});
// ============================================================================
// Fallback Behavior between HTTP and Delta Streams
// ============================================================================
describe("Fallback Behavior", () => {
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
let threadId: string;
beforeEach(async () => {
t = initConvexTest();
await t.run(async (ctx) => {
threadId = await createThread(ctx, components.agent, {});
});
});
test("aborted stream transitions to aborted state", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
await streamer.getStreamId();
await streamer.fail("User canceled");
const aborted = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["aborted"],
});
expect(aborted).toHaveLength(1);
expect(aborted[0].status).toBe("aborted");
// No streaming streams left
const streaming = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming"],
});
expect(streaming).toHaveLength(0);
});
});
test("abort via abortByOrder aborts all streams at that order", async () => {
await t.run(async (ctx) => {
// Create two streams at the same order (different stepOrders)
const s1 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 5, stepOrder: 0 },
);
await s1.getStreamId();
const s2 = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: 5, stepOrder: 1 },
);
await s2.getStreamId();
// Abort by order
const result = await ctx.runMutation(
components.agent.streams.abortByOrder,
{ threadId, order: 5, reason: "batch abort" },
);
expect(result).toBe(true);
const streaming = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming"],
});
expect(streaming).toHaveLength(0);
const aborted = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["aborted"],
});
expect(aborted).toHaveLength(2);
});
});
test("fail on already-aborted stream is a no-op", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
await streamer.getStreamId();
// First abort
await streamer.fail("First abort");
// Second abort is a no-op (no error thrown)
await streamer.fail("Second abort");
const aborted = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["aborted"],
});
expect(aborted).toHaveLength(1);
});
});
test("finish on non-existent stream is a no-op", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
// Calling finish without ever creating a stream should be safe
await streamer.finish();
expect(streamer.streamId).toBeUndefined();
});
});
test("deriveUIMessagesFromDeltas maps stream status correctly", async () => {
// Streaming status
const streamingMsg: StreamMessage = {
streamId: "s1",
order: 0,
stepOrder: 0,
status: "streaming",
format: "UIMessageChunk",
};
const finishedMsg: StreamMessage = {
streamId: "s2",
order: 1,
stepOrder: 0,
status: "finished",
format: "UIMessageChunk",
};
const abortedMsg: StreamMessage = {
streamId: "s3",
order: 2,
stepOrder: 0,
status: "aborted",
format: "UIMessageChunk",
};
const msgs = await deriveUIMessagesFromDeltas(
"t1",
[streamingMsg, finishedMsg, abortedMsg],
[],
);
expect(msgs[0].status).toBe("streaming");
expect(msgs[1].status).toBe("success");
expect(msgs[2].status).toBe("failed");
});
test("dedupeMessages handles fallback from streaming to finalized gracefully", () => {
type M = {
order: number;
stepOrder: number;
status: "pending" | "success" | "failed" | "streaming";
text: string;
};
// Simulate: full messages from DB include finalized versions, streaming
// messages are still around from the delta stream
const dbMessages: M[] = [
{ order: 1, stepOrder: 0, status: "success", text: "Final answer" },
{ order: 2, stepOrder: 0, status: "pending", text: "Thinking..." },
];
const streamMessages: M[] = [
{ order: 1, stepOrder: 0, status: "streaming", text: "Final ans..." },
{ order: 2, stepOrder: 0, status: "streaming", text: "Thinking..." },
];
const result = dedupeMessages(dbMessages, streamMessages);
// Order 1: finalized DB version preferred over streaming
expect(result[0].status).toBe("success");
expect(result[0].text).toBe("Final answer");
// Order 2: streaming preferred over pending DB version
expect(result[1].status).toBe("streaming");
});
test("mergeTransforms adds smoothStream when streaming is enabled", () => {
// No streaming options - returns existing transforms
expect(mergeTransforms(undefined, undefined)).toBeUndefined();
// Boolean true - adds smoothStream
const transforms = mergeTransforms(true, undefined);
expect(transforms).toBeDefined();
expect(Array.isArray(transforms)).toBe(true);
expect((transforms as any[]).length).toBe(1);
// With existing transforms - appends
const existing = [(chunk: any) => chunk];
const merged = mergeTransforms(true, existing);
expect(Array.isArray(merged)).toBe(true);
expect((merged as any[]).length).toBe(2);
// Custom chunking
const custom = mergeTransforms({ chunking: "word" }, undefined);
expect(custom).toBeDefined();
expect(Array.isArray(custom)).toBe(true);
});
});
// ============================================================================
// Stream Lifecycle Integration
// ============================================================================
describe("Stream Lifecycle Integration", () => {
let t: TestConvex<SchemaDefinition<GenericSchema, boolean>>;
let threadId: string;
beforeEach(async () => {
t = initConvexTest();
await t.run(async (ctx) => {
threadId = await createThread(ctx, components.agent, {});
});
});
test("full lifecycle: create -> stream -> finish -> derive messages", async () => {
await t.run(async (ctx) => {
// 1. Create the stream
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
// 2. Stream content
const result = streamText({
model: mockModel({
content: [
{ type: "text", text: "Once upon a time" },
{ type: "reasoning", text: "I should tell a story" },
],
}),
prompt: "Tell me a story",
});
await streamer.consumeStream(result.toUIMessageStream());
const streamId = streamer.streamId!;
// 3. Verify finish state
const finished = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["finished"],
});
expect(finished).toHaveLength(1);
// 4. Derive UI messages from stored deltas
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: 0, streamId }],
});
const uiMessages = await deriveUIMessagesFromDeltas(
threadId,
finished,
deltas,
);
expect(uiMessages).toHaveLength(1);
const msg = uiMessages[0];
expect(msg.role).toBe("assistant");
expect(msg.text).toContain("Once");
expect(msg.text).toContain("upon");
expect(msg.text).toContain("time");
expect(msg.status).toBe("success");
// Check that reasoning parts are present
const reasoningParts = msg.parts.filter(
(p: any) => p.type === "reasoning",
);
expect(reasoningParts.length).toBeGreaterThan(0);
});
});
test("full lifecycle: create -> partial stream -> abort -> derive aborted messages", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
// Stream some content then abort
await streamer.addParts([
{ type: "start" },
{ type: "start-step" },
{ type: "text-start", id: "txt-0" },
{ type: "text-delta", id: "txt-0", delta: "Partial" },
]);
await streamer.fail("User aborted");
const streamId = streamer.streamId!;
// Verify aborted state
const aborted = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["aborted"],
});
expect(aborted).toHaveLength(1);
expect(aborted[0].status).toBe("aborted");
// Even aborted streams have their deltas stored
const deltas = await ctx.runQuery(components.agent.streams.listDeltas, {
threadId,
cursors: [{ cursor: 0, streamId }],
});
expect(deltas.length).toBeGreaterThan(0);
});
});
test("multiple concurrent streams in same thread", async () => {
await t.run(async (ctx) => {
const streamers = [];
for (let i = 0; i < 3; i++) {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId, order: i },
);
const r = streamText({
model: mockModel({
content: [{ type: "text", text: `Message ${i}` }],
}),
prompt: "Test",
});
await streamer.consumeStream(r.toUIMessageStream());
streamers.push(streamer);
}
// All should be finished
const finished = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["finished"],
});
expect(finished).toHaveLength(3);
// Derive all messages
const allDeltas = await ctx.runQuery(
components.agent.streams.listDeltas,
{
threadId,
cursors: streamers.map((s) => ({
cursor: 0,
streamId: s.streamId!,
})),
},
);
const uiMessages = await deriveUIMessagesFromDeltas(
threadId,
finished,
allDeltas,
);
expect(uiMessages).toHaveLength(3);
});
});
test("stream deletion removes both stream and its deltas", async () => {
await t.run(async (ctx) => {
const streamer = new DeltaStreamer(
components.agent,
ctx,
{ ...defaultTestOptions },
{ ...testMetadata, threadId },
);
const r = streamText({
model: mockModel({ content: [{ type: "text", text: "Delete me" }] }),
prompt: "Test",
});
await streamer.consumeStream(r.toUIMessageStream());
const streamId = streamer.streamId!;
// Verify deltas exist
const beforeDeltas = await ctx.runQuery(
components.agent.streams.listDeltas,
{ threadId, cursors: [{ cursor: 0, streamId }] },
);
expect(beforeDeltas.length).toBeGreaterThan(0);
// Delete the stream
await ctx.runMutation(components.agent.streams.deleteStreamSync, {
streamId,
});
// Both stream and deltas should be gone
const afterStreams = await ctx.runQuery(components.agent.streams.list, {
threadId,
statuses: ["streaming", "finished", "aborted"],
});
expect(afterStreams).toHaveLength(0);
const afterDeltas = await ctx.runQuery(
components.agent.streams.listDeltas,
{ threadId, cursors: [{ cursor: 0, streamId }] },
);
expect(afterDeltas).toHaveLength(0);
});
});
});
// ============================================================================
// Compression
// ============================================================================
describe("Compression", () => {
test("compressUIMessageChunks merges consecutive text-delta parts", () => {
const parts = [
{ type: "text-delta" as const, id: "1", delta: "Hello" },
{ type: "text-delta" as const, id: "1", delta: " " },
{ type: "text-delta" as const, id: "1", delta: "World" },
];
const compressed = compressUIMessageChunks(parts);
expect(compressed).toHaveLength(1);
expect(compressed[0]).toEqual({
type: "text-delta",
id: "1",
delta: "Hello World",
});
});
test("compressUIMessageChunks does not merge different IDs", () => {
const parts = [
{ type: "text-delta" as const, id: "1", delta: "Hello" },
{ type: "text-delta" as const, id: "2", delta: "World" },
];
const compressed = compressUIMessageChunks(parts);
expect(compressed).toHaveLength(2);
});
test("compressUIMessageChunks merges consecutive reasoning-delta parts", () => {
const parts = [
{ type: "reasoning-delta" as const, id: "r1", delta: "Think" },
{ type: "reasoning-delta" as const, id: "r1", delta: "ing" },
];
const compressed = compressUIMessageChunks(parts);
expect(compressed).toHaveLength(1);
expect((compressed[0] as { delta: string }).delta).toBe("Thinking");
});
test("compressUIMessageChunks preserves non-delta parts", () => {
const parts = [
{ type: "start" as const },
{ type: "text-delta" as const, id: "1", delta: "A" },
{ type: "text-delta" as const, id: "1", delta: "B" },
{ type: "finish" as const },
];
const compressed = compressUIMessageChunks(parts as any);
expect(compressed).toHaveLength(3); // start, merged text, finish
});
});