@convex-dev/agent
Version:
A agent component for Convex.
312 lines (300 loc) • 9.73 kB
text/typescript
import { describe, it, expect } from "vitest";
import { mergeDeltas, applyDeltasToStreamMessage } from "./deltas.js";
import type { StreamMessage, StreamDelta } from "../validators.js";
import { omit } from "convex-helpers";
import type { TextStreamPart, ToolSet } from "ai";
function makeStreamMessage(
streamId: string,
order: number,
stepOrder: number,
): StreamMessage {
return { streamId, order, stepOrder } as StreamMessage;
}
function makeDelta(
streamId: string,
start: number,
end: number,
parts: TextStreamPart<ToolSet>[],
): StreamDelta {
return { streamId, start, end, parts };
}
describe("mergeDeltas", () => {
it("merges a single text-delta into a message", () => {
const streamId = "s1";
const streamMessages = [makeStreamMessage(streamId, 1, 0)];
const deltas = [
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
];
const [messages, newStreams, changed] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
expect(messages).toHaveLength(1);
expect(messages[0].text).toBe("Hello");
expect(messages[0].message?.role).toBe("assistant");
expect(changed).toBe(true);
expect(newStreams[0].cursor).toBe(5);
});
it("merges multiple deltas for the same stream", () => {
const streamId = "s1";
const streamMessages = [makeStreamMessage(streamId, 1, 0)];
const deltas = [
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
makeDelta(streamId, 5, 11, [
{ type: "text-delta", id: "2", text: " World!" },
]),
];
const [messages, newStreams, changed] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
expect(messages).toHaveLength(1);
expect(messages[0].text).toBe("Hello World!");
expect(changed).toBe(true);
expect(newStreams[0].cursor).toBe(11);
});
it("handles tool-call and tool-result parts", () => {
const streamId = "s2";
const streamMessages = [makeStreamMessage(streamId, 2, 0)];
const deltas = [
makeDelta(streamId, 0, 1, [
{
type: "tool-call",
toolCallId: "call1",
toolName: "myTool",
input: "",
},
]),
makeDelta(streamId, 1, 2, [
{
type: "tool-result",
toolCallId: "call1",
toolName: "myTool",
input: undefined,
output: "42",
},
]),
];
const [messages, _, changed] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
expect(messages).toHaveLength(2);
expect(messages[0].message?.role).toBe("assistant");
expect(messages[0].tool).toBe(true);
const content = messages[0].message?.content;
expect(content).toEqual([
{ type: "tool-call", toolCallId: "call1", toolName: "myTool", args: "" },
]);
expect(messages[1].message?.role).toBe("tool");
expect(messages[1].tool).toBe(true);
expect(messages[1].message?.content).toEqual([
{
type: "tool-result",
toolCallId: "call1",
toolName: "myTool",
result: "42",
},
]);
expect(changed).toBe(true);
});
it("returns changed=false if no new deltas", () => {
const streamId = "s3";
const streamMessages = [makeStreamMessage(streamId, 3, 0)];
const deltas: StreamDelta[] = [];
const [messages, newStreams, changed] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
expect(messages).toHaveLength(0);
expect(changed).toBe(false);
expect(newStreams[0].cursor).toBe(0);
});
it("handles multiple streams and sorts by order/stepOrder", () => {
const s1 = makeStreamMessage("s1", 1, 0);
const s2 = makeStreamMessage("s2", 2, 0);
const deltas = [
makeDelta("s2", 0, 3, [{ type: "text-delta", id: "1", text: "B" }]),
makeDelta("s1", 0, 3, [{ type: "text-delta", id: "2", text: "A" }]),
];
const [messages, _, changed] = mergeDeltas("thread1", [s2, s1], [], deltas);
expect(messages).toHaveLength(2);
expect(messages[0].text).toBe("A");
expect(messages[1].text).toBe("B");
expect(changed).toBe(true);
// Sorted by order
expect(messages[0].order).toBe(1);
expect(messages[1].order).toBe(2);
});
it("does not duplicate text content when merging sequential text-deltas", () => {
const streamId = "s4";
const streamMessages = [makeStreamMessage(streamId, 4, 0)];
const deltas = [
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
makeDelta(streamId, 5, 11, [
{ type: "text-delta", id: "2", text: " World!" },
]),
makeDelta(streamId, 11, 12, [{ type: "text-delta", id: "3", text: "!" }]),
];
const [messages] = mergeDeltas("thread1", streamMessages, [], deltas);
expect(messages).toHaveLength(1);
expect(messages[0].text).toBe("Hello World!!");
// There should only be one text part per message
const content = messages[0].message?.content;
if (Array.isArray(content)) {
const textParts = content.filter((p) => p.type === "text");
expect(textParts).toHaveLength(1);
expect(textParts[0].text).toBe("Hello World!!");
}
});
it("does not duplicate reasoning parts", () => {
const streamId = "s6";
const streamMessages = [makeStreamMessage(streamId, 6, 0)];
const deltas = [
makeDelta(streamId, 0, 1, [
{ type: "reasoning-delta", id: "1", text: "I'm thinking..." },
]),
makeDelta(streamId, 1, 2, [
{ type: "reasoning-delta", id: "2", text: " Still thinking..." },
]),
];
const [messages] = mergeDeltas("thread1", streamMessages, [], deltas);
expect(messages).toHaveLength(1);
if (Array.isArray(messages[0].message?.content)) {
const reasoningParts = messages[0].message.content.filter(
(p) => p.type === "reasoning",
);
expect(reasoningParts).toHaveLength(1);
expect(reasoningParts[0].text).toBe("I'm thinking... Still thinking...");
}
});
it("applyDeltasToStreamMessage is idempotent and does not duplicate content", () => {
const streamId = "s7";
const streamMessage = makeStreamMessage(streamId, 7, 0);
const deltas = [
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
makeDelta(streamId, 5, 11, [
{ type: "text-delta", id: "2", text: " World!" },
]),
];
// First call: apply both deltas
let [result, changed] = applyDeltasToStreamMessage(
"thread1",
streamMessage,
undefined,
deltas,
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("Hello World!");
// Second call: re-apply the same deltas (should not duplicate)
[result, changed] = applyDeltasToStreamMessage(
"thread1",
streamMessage,
result,
deltas,
);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("Hello World!");
// Third call: add a new delta
const moreDeltas = [
...deltas,
makeDelta(streamId, 11, 12, [{ type: "text-delta", id: "3", text: "!" }]),
];
[result, changed] = applyDeltasToStreamMessage(
"thread1",
streamMessage,
result,
moreDeltas,
);
expect(changed).toBe(true);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("Hello World!!");
// Re-apply all deltas again (should still not duplicate)
[result, changed] = applyDeltasToStreamMessage(
"thread1",
streamMessage,
result,
moreDeltas,
);
expect(changed).toBe(false);
expect(result.messages).toHaveLength(1);
expect(result.messages[0].text).toBe("Hello World!!");
});
it("mergeDeltas is pure and does not mutate inputs", () => {
const streamId = "s8";
const streamMessages = [makeStreamMessage(streamId, 8, 0)];
const deltas = [
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
makeDelta(streamId, 5, 11, [
{ type: "text-delta", id: "2", text: " World!" },
]),
];
// Deep freeze inputs to catch mutation
function deepFreeze(obj: unknown): unknown {
if (obj && typeof obj === "object" && !Object.isFrozen(obj)) {
Object.freeze(obj);
for (const key of Object.keys(obj)) {
deepFreeze((obj as Record<string, unknown>)[key]);
}
}
return obj;
}
deepFreeze(streamMessages);
deepFreeze(deltas);
const [messages1, streams1, changed1] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
const [messages2, streams2, changed2] = mergeDeltas(
"thread1",
streamMessages,
[],
deltas,
);
expect(messages1.map((m) => omit(m, ["_creationTime"]))).toEqual(
messages2.map((m) => omit(m, ["_creationTime"])),
);
expect(
streams1.map((s) => ({
...s,
messages: s.messages.map((m) => omit(m, ["_creationTime"])),
})),
).toEqual(
streams2.map((s) => ({
...s,
messages: s.messages.map((m) => omit(m, ["_creationTime"])),
})),
);
expect(changed1).toBe(changed2);
// Inputs should remain unchanged
expect(streamMessages).toEqual([makeStreamMessage(streamId, 8, 0)]);
expect(deltas).toEqual([
makeDelta(streamId, 0, 5, [
{ type: "text-delta", id: "1", text: "Hello" },
]),
makeDelta(streamId, 5, 11, [
{ type: "text-delta", id: "2", text: " World!" },
]),
]);
});
});