Skip to content

Commit 426edca

Browse files
committed
test(stories): add sample conversations for the chat stories
Add deterministic samples built only from the conversation contract: a short chat, a 300-message history, a tool storm with a batch, a failure and every approval outcome, generated images, a thread and a failed send. playTurn reveals a message's parts over time for streaming stories. Images are inline SVG, timestamps are fixed, and nothing reads the clock or Math.random.
1 parent 6c54d1d commit 426edca

5 files changed

Lines changed: 1224 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import { isAwaitingApproval } from "../../../src/lib/conversation-activity.js";
4+
import type {
5+
ConversationMessage,
6+
ConversationPart,
7+
PartTool,
8+
} from "../../../src/lib/conversation.js";
9+
import {
10+
SAMPLE_NOW,
11+
failedSend,
12+
generatedImages,
13+
longChat,
14+
samples,
15+
toolStorm,
16+
withThread,
17+
} from "./conversations.js";
18+
19+
function everyMessage(sample: (typeof samples)[number]) {
20+
return [...sample.messages, ...(sample.thread?.replies ?? [])];
21+
}
22+
23+
function toolsOf(parts: readonly ConversationPart[]): readonly PartTool[] {
24+
return parts.flatMap((part) => {
25+
if (part.kind === "tool-trace") return [part];
26+
if (part.kind === "tool-batch") return part.tools;
27+
return [];
28+
});
29+
}
30+
31+
function allParts(messages: readonly ConversationMessage[]) {
32+
return messages.flatMap((message) => message.parts);
33+
}
34+
35+
describe.each(samples.map((sample) => [sample.title, sample] as const))(
36+
"%s",
37+
(_, sample) => {
38+
test("has unique message ids, thread included", () => {
39+
const ids = everyMessage(sample).map((message) => message.id);
40+
expect(new Set(ids).size).toBe(ids.length);
41+
});
42+
43+
test("never goes back in time and stays before SAMPLE_NOW", () => {
44+
for (const list of [sample.messages, sample.thread?.replies ?? []]) {
45+
const times = list.map((message) => Date.parse(message.at));
46+
expect(times).toEqual([...times].sort((a, b) => a - b));
47+
for (const time of times) {
48+
expect(time).toBeLessThan(Date.parse(SAMPLE_NOW));
49+
}
50+
}
51+
});
52+
53+
test("uses generic tool names and example.com sources", () => {
54+
const parts = allParts(everyMessage(sample));
55+
for (const tool of toolsOf(parts)) {
56+
expect(tool.name).toMatch(
57+
/^(runs|files|web|calendar|notes|mail|records|image)__[a-z_]+$/,
58+
);
59+
}
60+
for (const part of parts) {
61+
if (part.kind !== "sources") continue;
62+
for (const source of part.sources) {
63+
expect(new URL(source.url).hostname).toBe("example.com");
64+
}
65+
}
66+
});
67+
68+
test("gives every image alt text and keeps it off the network", () => {
69+
for (const part of allParts(sample.messages)) {
70+
if (part.kind !== "image") continue;
71+
for (const image of part.images) {
72+
expect(image.alt).toBeTruthy();
73+
if (image.url !== undefined) {
74+
expect(image.url.startsWith("data:")).toBe(true);
75+
}
76+
}
77+
}
78+
});
79+
},
80+
);
81+
82+
test("samples read no randomness and no clock", async () => {
83+
const source = await Bun.file(
84+
new URL("./conversations.ts", import.meta.url),
85+
).text();
86+
expect(source).not.toMatch(/Math\.random|Date\.now|new Date\(\)/);
87+
});
88+
89+
test("longChat holds a few hundred messages across more than one day", () => {
90+
expect(longChat.messages.length).toBeGreaterThanOrEqual(200);
91+
const days = new Set(
92+
longChat.messages.map((message) => message.at.slice(0, 10)),
93+
);
94+
expect(days.size).toBeGreaterThan(1);
95+
});
96+
97+
test("toolStorm covers a batch, a failure and every approval outcome", () => {
98+
const parts = allParts(toolStorm.messages);
99+
expect(parts.some((part) => part.kind === "tool-batch")).toBe(true);
100+
const tools = toolsOf(parts);
101+
expect(tools.length).toBeGreaterThan(40);
102+
expect(tools.some((tool) => tool.status === "error")).toBe(true);
103+
expect(new Set(tools.map((tool) => tool.approval?.status))).toEqual(
104+
new Set([
105+
undefined,
106+
"pending",
107+
"approved",
108+
"rejected",
109+
"timeout",
110+
"expired",
111+
]),
112+
);
113+
expect(
114+
new Set(tools.map((tool) => tool.approval?.scope).filter(Boolean)),
115+
).toEqual(new Set(["once", "always"]));
116+
expect(tools.filter(isAwaitingApproval)).toHaveLength(1);
117+
});
118+
119+
test("toolStorm's calls that never ran carry no output", () => {
120+
for (const tool of toolsOf(allParts(toolStorm.messages))) {
121+
if (tool.status === "output-denied") {
122+
expect(tool.output).toBeUndefined();
123+
}
124+
}
125+
});
126+
127+
test("generatedImages covers both storage forms and every image state", () => {
128+
const images = allParts(generatedImages.messages).flatMap((part) =>
129+
part.kind === "image" ? [part] : [],
130+
);
131+
expect(new Set(images.map((part) => part.state))).toEqual(
132+
new Set(["done", "generating", "error"]),
133+
);
134+
const stored = images.flatMap((part) => part.images);
135+
expect(stored.some((image) => image.base64 !== undefined)).toBe(true);
136+
expect(stored.some((image) => image.url !== undefined)).toBe(true);
137+
const generating = images.find((part) => part.state === "generating");
138+
expect(generating?.expected?.count).toBeGreaterThan(0);
139+
});
140+
141+
test("withThread's reply summary matches its thread", () => {
142+
const thread = withThread.thread;
143+
if (thread === undefined) throw new Error("withThread has no thread");
144+
const parent = withThread.messages.find(
145+
(message) => message.id === thread.parentId,
146+
);
147+
expect(parent?.replies).toEqual({
148+
count: thread.replies.length,
149+
lastAt: thread.replies.at(-1)?.at ?? "",
150+
});
151+
});
152+
153+
test("failedSend has a failed send with a reason and one still sending", () => {
154+
const failed = failedSend.messages.find(
155+
(message) => message.status === "failed",
156+
);
157+
expect(failed?.error).toBeTruthy();
158+
expect(failedSend.messages.some((m) => m.status === "sending")).toBe(true);
159+
});

0 commit comments

Comments
 (0)