From 45e331c1e9e765ced2b09364735c2339c18bd9f1 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Thu, 22 Oct 2026 00:04:34 +0530 Subject: [PATCH 1/3] 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. --- stories/chat/samples/conversations.test.ts | 159 ++++++ stories/chat/samples/conversations.ts | 591 +++++++++++++++++++++ stories/chat/samples/play-turn.test.ts | 238 +++++++++ stories/chat/samples/play-turn.ts | 179 +++++++ stories/chat/samples/samples.stories.tsx | 57 ++ 5 files changed, 1224 insertions(+) create mode 100644 stories/chat/samples/conversations.test.ts create mode 100644 stories/chat/samples/conversations.ts create mode 100644 stories/chat/samples/play-turn.test.ts create mode 100644 stories/chat/samples/play-turn.ts create mode 100644 stories/chat/samples/samples.stories.tsx diff --git a/stories/chat/samples/conversations.test.ts b/stories/chat/samples/conversations.test.ts new file mode 100644 index 00000000..0d757445 --- /dev/null +++ b/stories/chat/samples/conversations.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from "bun:test"; + +import { isAwaitingApproval } from "../../../src/lib/conversation-activity.js"; +import type { + ConversationMessage, + ConversationPart, + PartTool, +} from "../../../src/lib/conversation.js"; +import { + SAMPLE_NOW, + failedSend, + generatedImages, + longChat, + samples, + toolStorm, + withThread, +} from "./conversations.js"; + +function everyMessage(sample: (typeof samples)[number]) { + return [...sample.messages, ...(sample.thread?.replies ?? [])]; +} + +function toolsOf(parts: readonly ConversationPart[]): readonly PartTool[] { + return parts.flatMap((part) => { + if (part.kind === "tool-trace") return [part]; + if (part.kind === "tool-batch") return part.tools; + return []; + }); +} + +function allParts(messages: readonly ConversationMessage[]) { + return messages.flatMap((message) => message.parts); +} + +describe.each(samples.map((sample) => [sample.title, sample] as const))( + "%s", + (_, sample) => { + test("has unique message ids, thread included", () => { + const ids = everyMessage(sample).map((message) => message.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + test("never goes back in time and stays before SAMPLE_NOW", () => { + for (const list of [sample.messages, sample.thread?.replies ?? []]) { + const times = list.map((message) => Date.parse(message.at)); + expect(times).toEqual([...times].sort((a, b) => a - b)); + for (const time of times) { + expect(time).toBeLessThan(Date.parse(SAMPLE_NOW)); + } + } + }); + + test("uses generic tool names and example.com sources", () => { + const parts = allParts(everyMessage(sample)); + for (const tool of toolsOf(parts)) { + expect(tool.name).toMatch( + /^(runs|files|web|calendar|notes|mail|records|image)__[a-z_]+$/, + ); + } + for (const part of parts) { + if (part.kind !== "sources") continue; + for (const source of part.sources) { + expect(new URL(source.url).hostname).toBe("example.com"); + } + } + }); + + test("gives every image alt text and keeps it off the network", () => { + for (const part of allParts(sample.messages)) { + if (part.kind !== "image") continue; + for (const image of part.images) { + expect(image.alt).toBeTruthy(); + if (image.url !== undefined) { + expect(image.url.startsWith("data:")).toBe(true); + } + } + } + }); + }, +); + +test("samples read no randomness and no clock", async () => { + const source = await Bun.file( + new URL("./conversations.ts", import.meta.url), + ).text(); + expect(source).not.toMatch(/Math\.random|Date\.now|new Date\(\)/); +}); + +test("longChat holds a few hundred messages across more than one day", () => { + expect(longChat.messages.length).toBeGreaterThanOrEqual(200); + const days = new Set( + longChat.messages.map((message) => message.at.slice(0, 10)), + ); + expect(days.size).toBeGreaterThan(1); +}); + +test("toolStorm covers a batch, a failure and every approval outcome", () => { + const parts = allParts(toolStorm.messages); + expect(parts.some((part) => part.kind === "tool-batch")).toBe(true); + const tools = toolsOf(parts); + expect(tools.length).toBeGreaterThan(40); + expect(tools.some((tool) => tool.status === "error")).toBe(true); + expect(new Set(tools.map((tool) => tool.approval?.status))).toEqual( + new Set([ + undefined, + "pending", + "approved", + "rejected", + "timeout", + "expired", + ]), + ); + expect( + new Set(tools.map((tool) => tool.approval?.scope).filter(Boolean)), + ).toEqual(new Set(["once", "always"])); + expect(tools.filter(isAwaitingApproval)).toHaveLength(1); +}); + +test("toolStorm's calls that never ran carry no output", () => { + for (const tool of toolsOf(allParts(toolStorm.messages))) { + if (tool.status === "output-denied") { + expect(tool.output).toBeUndefined(); + } + } +}); + +test("generatedImages covers both storage forms and every image state", () => { + const images = allParts(generatedImages.messages).flatMap((part) => + part.kind === "image" ? [part] : [], + ); + expect(new Set(images.map((part) => part.state))).toEqual( + new Set(["done", "generating", "error"]), + ); + const stored = images.flatMap((part) => part.images); + expect(stored.some((image) => image.base64 !== undefined)).toBe(true); + expect(stored.some((image) => image.url !== undefined)).toBe(true); + const generating = images.find((part) => part.state === "generating"); + expect(generating?.expected?.count).toBeGreaterThan(0); +}); + +test("withThread's reply summary matches its thread", () => { + const thread = withThread.thread; + if (thread === undefined) throw new Error("withThread has no thread"); + const parent = withThread.messages.find( + (message) => message.id === thread.parentId, + ); + expect(parent?.replies).toEqual({ + count: thread.replies.length, + lastAt: thread.replies.at(-1)?.at ?? "", + }); +}); + +test("failedSend has a failed send with a reason and one still sending", () => { + const failed = failedSend.messages.find( + (message) => message.status === "failed", + ); + expect(failed?.error).toBeTruthy(); + expect(failedSend.messages.some((m) => m.status === "sending")).toBe(true); +}); diff --git a/stories/chat/samples/conversations.ts b/stories/chat/samples/conversations.ts new file mode 100644 index 00000000..045a27b7 --- /dev/null +++ b/stories/chat/samples/conversations.ts @@ -0,0 +1,591 @@ +/** + * Sample conversations for the chat stories, built only from the contract + * types in `src/lib/conversation.ts`. Everything here is deterministic: ids + * and timestamps are fixed, and images are inline SVG, so a story renders the + * same on every run and never touches the network. + */ + +import type { + ConversationImage, + ConversationMessage, + ConversationParticipant, + ConversationPart, + PartTool, +} from "../../../src/lib/conversation.js"; + +/** + * Every sample timestamp sits before this instant. Stories that show + * relative times or deadlines pass it as their `now` so the output does not + * drift with the real clock. + */ +export const SAMPLE_NOW = "2026-03-04T12:00:00.000Z"; + +const NOW_MS = Date.parse(SAMPLE_NOW); + +function minutesBefore(minutes: number): string { + return new Date(NOW_MS - minutes * 60_000).toISOString(); +} + +/** The viewer: the person whose own sends are `sending` or `failed`. */ +export const you: ConversationParticipant = { + id: "person-you", + kind: "person", + name: "Alex Rivera", +}; + +export const teammate: ConversationParticipant = { + id: "person-teammate", + kind: "person", + name: "Sam Chen", +}; + +export const assistant: ConversationParticipant = { + id: "agent-assistant", + kind: "agent", + name: "Ada", + role: "Assistant", +}; + +export const reviewer: ConversationParticipant = { + id: "agent-reviewer", + kind: "agent", + name: "Iris", + role: "Review", +}; + +export type ConversationSample = { + readonly title: string; + readonly messages: readonly ConversationMessage[]; + /** Replies to one message, shown apart from the main conversation. */ + readonly thread?: { + readonly parentId: string; + readonly replies: readonly ConversationMessage[]; + }; +}; + +type MessageExtras = Pick< + ConversationMessage, + "status" | "error" | "replies" | "reactions" +>; + +function message( + id: string, + author: ConversationParticipant, + at: string, + parts: readonly ConversationPart[], + extras: Partial = {}, +): ConversationMessage { + return { id, author, at, status: "sent", parts, ...extras }; +} + +function text(value: string): ConversationPart { + return { kind: "text", text: value }; +} + +function call( + id: string, + name: string, + input: unknown, + output: unknown, +): PartTool { + return { + kind: "tool-trace", + toolCallId: id, + name, + status: "output-available", + input, + output, + }; +} + +export const shortChat: ConversationSample = { + title: "Short chat", + messages: [ + message("short-1", you, minutesBefore(42), [ + text( + "What changed in the workflow this week? Attaching the plan so you know what matters.", + ), + { kind: "file", name: "quarter-plan.pdf", mediaType: "application/pdf" }, + ]), + message("short-2", assistant, minutesBefore(41), [ + { + kind: "reasoning", + text: "List the runs since Monday, compare them with the plan, and call out anything that touches deploys.", + durationMs: 6_200, + }, + call( + "short-call-1", + "runs__list", + { since: "monday" }, + "14 runs since Monday", + ), + call("short-call-2", "runs__read_log", { runs: 4 }, "Read 4 run logs"), + text( + "Fourteen runs finished this week. Two touch the deploy step and carry the risk:\n\n- **Retry cap** now allows 3 restarts in 10 minutes.\n- **Health check** waits longer before marking a run stopped.\n\nEverything else matches the plan.", + ), + { + kind: "sources", + sources: [ + { + title: "Retry cap change", + url: "https://example.com/changes/retry-cap", + }, + { + title: "Health check timing", + url: "https://example.com/changes/health-check", + }, + ], + }, + ]), + message("short-3", you, minutesBefore(7), [ + text("Thanks. Keep an eye on the retry cap next week."), + ]), + message( + "short-4", + assistant, + minutesBefore(6), + [text("Will do. I'll flag it if a run restarts more than twice.")], + { reactions: [{ emoji: "👍", count: 1, mine: true }] }, + ), + ], +}; + +const LONG_QUESTIONS = [ + "What's the status of the export step?", + "Can you summarise the open items?", + "Which step took the longest?", + "Did anything fail overnight?", + "Draft a note for the team about it.", +]; + +const LONG_ANSWERS = [ + "Done. I've updated the summary and pinned it to the workflow.", + "Here's the breakdown:\n\n- **3** deploys\n- **1** rollback\n- **0** open incidents", + "The fetch step took most of the time, about forty seconds, because it paged through every result before filtering.", + "Nothing failed. One call was retried once after a slow response.", + "That change touches access rules, so I'd hold it until someone reviews it.", +]; + +function generateLongChat(count: number): readonly ConversationMessage[] { + return Array.from({ length: count }, (_, index) => { + // Seven minutes apart, so a few hundred messages span more than a day. + const at = minutesBefore((count - index) * 7); + const turn = Math.floor(index / 2); + if (index % 2 === 0) { + return message(`long-${index}`, you, at, [ + text(LONG_QUESTIONS[turn % LONG_QUESTIONS.length] ?? ""), + ]); + } + const answer = text(LONG_ANSWERS[turn % LONG_ANSWERS.length] ?? ""); + const author = turn % 9 === 4 ? reviewer : assistant; + if (turn % 6 !== 1) return message(`long-${index}`, author, at, [answer]); + return message(`long-${index}`, author, at, [ + { + kind: "reasoning", + text: "Check the latest runs before answering.", + durationMs: 1_800, + }, + call(`long-call-${index}`, "runs__list", { limit: 20 }, "20 runs"), + answer, + ]); + }); +} + +export const longChat: ConversationSample = { + title: "Long chat", + messages: generateLongChat(300), +}; + +const AGREEMENT_COUNT = 40; +const UNREADABLE_AGREEMENT = 27; +const PRICING_PAGES = 6; + +function readAgreement(index: number): PartTool { + const id = `storm-read-${index}`; + const input = { path: `agreements/agreement-${index + 1}.pdf` }; + if (index === UNREADABLE_AGREEMENT) { + return { + kind: "tool-trace", + toolCallId: id, + name: "files__read", + status: "error", + input, + output: "The file is password protected and could not be opened.", + }; + } + return call( + id, + "files__read", + input, + index % 4 === 0 ? "Renews this quarter" : "Renews next year", + ); +} + +/** + * Settled approvals carry their outcome in the record. A call a person + * refused, or that waited past its deadline, never ran, so its execution + * status is `output-denied` and it has no output. + */ +const resolvedApprovals: readonly PartTool[] = [ + { + ...call( + "storm-approved-once", + "calendar__create_event", + { title: "Renewal review", day: "Thursday" }, + "Event created", + ), + approval: { + id: "approval-once", + status: "approved", + scope: "once", + timeoutAt: null, + }, + }, + { + ...call( + "storm-approved-always", + "notes__append", + { note: "renewals", line: "14 renew this quarter" }, + "Line added", + ), + approval: { + id: "approval-always", + status: "approved", + scope: "always", + timeoutAt: null, + }, + }, + { + kind: "tool-trace", + toolCallId: "storm-rejected", + name: "mail__send", + status: "output-denied", + input: { to: "vendors", subject: "Renewal notice" }, + approval: { + id: "approval-rejected", + status: "rejected", + scope: "once", + timeoutAt: null, + }, + }, + { + kind: "tool-trace", + toolCallId: "storm-timeout", + name: "mail__send", + status: "output-denied", + input: { to: "finance", subject: "Renewal totals" }, + approval: { + id: "approval-timeout", + status: "timeout", + scope: null, + timeoutAt: minutesBefore(70), + }, + }, + { + kind: "tool-trace", + toolCallId: "storm-expired", + name: "records__update", + status: "output-denied", + input: { record: "agreement-12", field: "owner" }, + approval: { + id: "approval-expired", + status: "expired", + scope: null, + timeoutAt: minutesBefore(65), + }, + }, +]; + +export const toolStorm: ConversationSample = { + title: "Tool storm", + messages: [ + message("storm-1", you, minutesBefore(120), [ + text("Audit every vendor agreement and flag what renews this quarter."), + ]), + // Text between the stretches of work splits them into separate activity + // runs, so the batch and the settled approvals are not folded together + // with the forty reads. + message("storm-2", assistant, minutesBefore(110), [ + { + kind: "reasoning", + text: "Read every agreement first, then check current pricing for the ones that renew, then ask before writing anything.", + durationMs: 4_400, + }, + ...Array.from({ length: AGREEMENT_COUNT }, (_, index) => + readAgreement(index), + ), + text( + "Read 40 agreements. **10 renew this quarter**; one file couldn't be opened. Checking the pricing pages at once.", + ), + { + kind: "tool-batch", + id: "storm-batch", + tools: Array.from({ length: PRICING_PAGES }, (_, index) => + call( + `storm-fetch-${index}`, + "web__fetch", + { url: `https://example.com/pricing/vendor-${index + 1}` }, + index === 2 || index === 4 ? "Price changed" : "No change", + ), + ), + }, + text("Two vendors changed prices. These steps needed your sign-off:"), + ...resolvedApprovals, + text( + "The review is on your calendar and the notes are updated. The notice and the totals were not sent, and the owner change lapsed.", + ), + ]), + message("storm-3", you, minutesBefore(5), [ + text("Send the renewal summary to the team."), + ]), + // The turn is complete and paused on a person, not still arriving, so it + // is `sent` rather than `streaming`. + message("storm-4", assistant, minutesBefore(4), [ + call( + "storm-draft", + "mail__draft", + { to: "team", subject: "Renewals this quarter" }, + "Draft ready", + ), + { + kind: "tool-trace", + toolCallId: "storm-pending", + name: "mail__send", + status: "approval-requested", + input: { to: "team", subject: "Renewals this quarter" }, + // No deadline, so the sample never reads as overdue whatever the clock says. + approval: { + id: "approval-pending", + status: "pending", + scope: null, + timeoutAt: null, + }, + }, + ]), + ], +}; + +const ART_PALETTES: readonly (readonly [string, string, string])[] = [ + ["#f7ead5", "#e98428", "#2b2627"], + ["#2b2627", "#e98428", "#f2b277"], + ["#c5d2de", "#607c9a", "#2b2627"], + ["#c1d1be", "#7b9974", "#2b2627"], + ["#f2b277", "#9e4a45", "#f7ead5"], +]; + +/** An abstract composition standing in for a generated image. ASCII only, so it base64-encodes with `btoa`. */ +function artSVG(seed: number, width: number, height: number): string { + const [ground, accent, ink] = ART_PALETTES[seed % ART_PALETTES.length] ?? [ + "#ffffff", + "#000000", + "#000000", + ]; + const unit = Math.min(width, height); + const cx = width * (0.3 + ((seed * 37) % 40) / 100); + const cy = height * (0.35 + ((seed * 23) % 30) / 100); + return ( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ); +} + +/** Inline base64, the way a host holding the image bytes would pass it. */ +function inlineArt( + seed: number, + width: number, + height: number, + alt: string, +): ConversationImage { + return { + id: `art-${seed}-${width}x${height}`, + mediaType: "image/svg+xml", + width, + height, + alt, + base64: btoa(artSVG(seed, width, height)), + }; +} + +/** Behind a URL, the way a host serving stored images would pass it. */ +function linkedArt( + seed: number, + width: number, + height: number, + alt: string, +): ConversationImage { + return { + id: `art-${seed}-${width}x${height}`, + mediaType: "image/svg+xml", + width, + height, + alt, + url: `data:image/svg+xml,${encodeURIComponent(artSVG(seed, width, height))}`, + }; +} + +export const generatedImages: ConversationSample = { + title: "Generated images", + messages: [ + message("images-1", you, minutesBefore(30), [ + text("Make a cover image for the quarterly review deck. Warm, minimal."), + ]), + message("images-2", assistant, minutesBefore(29), [ + { + kind: "reasoning", + text: "Four directions, landscape for slides, warm palette.", + durationMs: 3_000, + }, + call( + "images-call-1", + "image__generate", + { count: 4, width: 1536, height: 1024 }, + "Generated 4 images", + ), + { + kind: "image", + id: "images-part-1", + prompt: + "Cover for the quarterly review deck: warm, minimal, one mountain motif", + state: "done", + images: [1, 2, 3, 4].map((seed) => + inlineArt(seed, 1536, 1024, `Deck cover option ${seed}`), + ), + }, + text("Four directions. The second matches the deck's orange best."), + ]), + message("images-3", you, minutesBefore(25), [ + text("Make a square version of the second one."), + ]), + message("images-4", assistant, minutesBefore(24), [ + { + kind: "image", + id: "images-part-2", + prompt: "Square crop of option 2", + state: "done", + images: [linkedArt(2, 1024, 1024, "Square version of option 2")], + }, + text("Here it is at 1024 × 1024."), + ]), + message("images-5", you, minutesBefore(21), [text("And a tall one.")]), + message("images-6", assistant, minutesBefore(20), [ + { + kind: "image", + id: "images-part-3", + prompt: "Tall 9:16 version of option 2", + state: "error", + images: [], + }, + text("The image tool timed out on that one."), + ]), + message("images-7", you, minutesBefore(2), [ + text("Try two quick square takes instead."), + ]), + message( + "images-8", + assistant, + minutesBefore(1), + [ + { + kind: "image", + id: "images-part-4", + prompt: "Two square takes of option 2", + state: "generating", + images: [], + expected: { count: 2, width: 1024, height: 1024 }, + }, + ], + { status: "streaming" }, + ), + ], +}; + +const threadReplies: readonly ConversationMessage[] = [ + message("thread-reply-1", teammate, minutesBefore(12), [ + text( + "Can the report list follow-ups too? I'd like to track them next week.", + ), + ]), + message("thread-reply-2", assistant, minutesBefore(10), [ + text( + "Added a follow-ups section with three items, the missing test first.", + ), + ]), +]; + +export const withThread: ConversationSample = { + title: "With a thread", + messages: [ + message("thread-1", you, minutesBefore(42), [ + text("Write up this week's runs as a report."), + ]), + message( + "thread-2", + assistant, + minutesBefore(41), + [ + text( + "Here's the report. Fourteen runs, two with deploy changes, no failures.", + ), + { + kind: "block", + block: { + type: "report", + data: { title: "Weekly runs", sections: 3 }, + }, + }, + ], + { + replies: { count: threadReplies.length, lastAt: minutesBefore(10) }, + reactions: [ + { emoji: "🎉", count: 2, mine: true }, + { emoji: "👀", count: 1, mine: false }, + ], + }, + ), + message("thread-3", teammate, minutesBefore(8), [ + text("Thanks, that's what I needed."), + ]), + ], + thread: { parentId: "thread-2", replies: threadReplies }, +}; + +export const failedSend: ConversationSample = { + title: "Failed send", + messages: [ + message("failed-1", you, minutesBefore(9), [ + text("Summarise the last three incidents."), + ]), + message("failed-2", assistant, minutesBefore(8), [ + text( + "Three incidents this month: a slow export, a stuck approval, and a retry loop. All three are closed.", + ), + ]), + message( + "failed-3", + you, + minutesBefore(2), + [text("Which one took longest to close?")], + { + status: "failed", + error: "Couldn't send. Check your connection, then try again.", + }, + ), + message("failed-4", you, minutesBefore(1), [text("And who closed it?")], { + status: "sending", + }), + ], +}; + +export const samples: readonly ConversationSample[] = [ + shortChat, + longChat, + toolStorm, + generatedImages, + withThread, + failedSend, +]; diff --git a/stories/chat/samples/play-turn.test.ts b/stories/chat/samples/play-turn.test.ts new file mode 100644 index 00000000..f820c149 --- /dev/null +++ b/stories/chat/samples/play-turn.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test"; + +import type { + ConversationMessage, + ConversationPart, + PartImage, + PartTool, + PartToolBatch, +} from "../../../src/lib/conversation.js"; +import { assistant, generatedImages, toolStorm } from "./conversations.js"; +import { playTurn, turnFrames } from "./play-turn.js"; + +function turn(parts: readonly ConversationPart[]): ConversationMessage { + return { + id: "turn", + author: assistant, + at: "2026-03-04T11:00:00.000Z", + status: "sent", + parts, + reactions: [{ emoji: "👍", count: 1, mine: false }], + }; +} + +function tool(overrides: Partial = {}): PartTool { + return { + kind: "tool-trace", + toolCallId: "call", + name: "runs__list", + status: "output-available", + input: { limit: 1 }, + output: "1 run", + ...overrides, + }; +} + +function lastPart(frame: ConversationMessage | undefined) { + return frame?.parts.at(-1); +} + +describe("turnFrames", () => { + test("starts empty and streaming, and ends with the message itself", () => { + const message = turn([{ kind: "text", text: "Done." }]); + const frames = turnFrames(message); + expect(frames[0]).toEqual({ + id: "turn", + author: assistant, + at: message.at, + status: "streaming", + parts: [], + }); + expect(frames.at(-1)).toBe(message); + expect(frames.slice(0, -1).every((f) => f.status === "streaming")).toBe( + true, + ); + }); + + test("reveals text a few words at a time", () => { + const words = "one two three four five six seven eight nine ten"; + const frames = turnFrames(turn([{ kind: "text", text: words }])); + expect(frames.map((frame) => lastPart(frame))).toEqual([ + undefined, + { kind: "text", text: "one two three four" }, + { kind: "text", text: "one two three four five six seven eight" }, + { kind: "text", text: words }, + ]); + }); + + test("adds a reasoning duration only once the thinking is complete", () => { + const thought = { + kind: "reasoning", + text: "a b c d e f", + durationMs: 900, + } as const; + const frames = turnFrames(turn([thought, { kind: "text", text: "ok" }])); + expect(lastPart(frames[1])).toEqual({ kind: "reasoning", text: "a b c d" }); + expect(frames[2]?.parts).toEqual([thought]); + }); + + test("runs a plain call, then settles it", () => { + const call = tool(); + const frames = turnFrames(turn([call])); + expect(frames.map((frame) => lastPart(frame))).toEqual([ + undefined, + { + kind: "tool-trace", + toolCallId: "call", + name: "runs__list", + status: "running", + input: { limit: 1 }, + }, + call, + ]); + }); + + test("shows a resolved approval as waiting first, never as running", () => { + const call = tool({ + status: "output-denied", + output: undefined, + approval: { + id: "a", + status: "rejected", + scope: "once", + timeoutAt: null, + }, + }); + const stages = turnFrames(turn([call])).map((frame) => lastPart(frame)); + expect(stages).toHaveLength(3); + const waiting = stages[1]; + expect(waiting?.kind === "tool-trace" && waiting.status).toBe( + "approval-requested", + ); + expect(waiting?.kind === "tool-trace" && waiting.approval).toEqual({ + id: "a", + status: "pending", + scope: null, + timeoutAt: null, + }); + expect(stages[2]).toBe(call); + }); + + test("shows a pending approval in its final form at once", () => { + const pending = toolStorm.messages.at(-1); + if (pending === undefined) throw new Error("toolStorm has no messages"); + const frames = turnFrames(pending); + const statuses = frames.flatMap((frame) => + frame.parts.flatMap((part) => + part.kind === "tool-trace" && part.name === "mail__send" + ? [part.status] + : [], + ), + ); + expect(new Set(statuses)).toEqual(new Set(["approval-requested"])); + }); + + test("starts every call in a batch together and finishes them one by one", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch", + tools: [tool({ toolCallId: "a" }), tool({ toolCallId: "b" })], + }; + const stages = turnFrames(turn([batch])).map((frame) => { + const part = lastPart(frame); + return part?.kind === "tool-batch" + ? part.tools.map((call) => call.status) + : null; + }); + expect(stages).toEqual([ + null, + ["running", "running"], + ["output-available", "running"], + ["output-available", "output-available"], + ]); + }); + + test("holds an image's space while it generates", () => { + const images = generatedImages.messages[1]?.parts.find( + (part): part is PartImage => part.kind === "image", + ); + if (images === undefined) throw new Error("no image part in the sample"); + const frames = turnFrames(turn([images])); + expect(lastPart(frames[1])).toEqual({ + kind: "image", + id: images.id, + prompt: images.prompt, + state: "generating", + images: [], + expected: { count: 4, width: 1536, height: 1024 }, + }); + expect(lastPart(frames[2])).toBe(images); + }); + + test("skips the generating frame when no image arrived", () => { + const failed: PartImage = { + kind: "image", + id: "img", + prompt: "p", + state: "error", + images: [], + }; + expect(turnFrames(turn([failed])).map(lastPart)).toEqual([ + undefined, + failed, + ]); + }); + + test("keeps earlier parts settled while a later one streams", () => { + const first = tool(); + const frames = turnFrames( + turn([first, { kind: "text", text: "a b c d e f g h i" }]), + ); + for (const frame of frames.slice(2)) expect(frame.parts[0]).toBe(first); + }); +}); + +describe("playTurn", () => { + const message = turn([{ kind: "text", text: "a b c d e f g h i j" }]); + + test("delivers every frame in order, the first at once", async () => { + const seen: ConversationMessage[] = []; + const finished = new Promise((resolve) => { + playTurn( + message, + (frame) => { + seen.push(frame); + if (frame === message) resolve(); + }, + { stepMs: 1 }, + ); + expect(seen).toHaveLength(1); + }); + await finished; + expect(seen).toEqual([...turnFrames(message)]); + }); + + test("delivers nothing after stop", async () => { + const seen: ConversationMessage[] = []; + const stop = playTurn(message, (frame) => seen.push(frame), { stepMs: 1 }); + stop(); + stop(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toHaveLength(1); + }); + + test("stops when the frame handler calls stop", async () => { + const seen: ConversationMessage[] = []; + let stop = () => {}; + stop = playTurn( + message, + (frame) => { + seen.push(frame); + if (seen.length === 2) stop(); + }, + { stepMs: 1 }, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(seen).toHaveLength(2); + }); +}); diff --git a/stories/chat/samples/play-turn.ts b/stories/chat/samples/play-turn.ts new file mode 100644 index 00000000..697d0c2f --- /dev/null +++ b/stories/chat/samples/play-turn.ts @@ -0,0 +1,179 @@ +/** + * Replays a finished message as if it were arriving, for streaming stories: + * text and thinking appear a few words at a time, tool calls start running + * and then settle, a batch's calls finish one by one, and images hold their + * space while they generate. + */ + +import type { + ConversationMessage, + ConversationPart, + PartImage, + PartTool, +} from "../../../src/lib/conversation.js"; + +const WORDS_PER_FRAME = 4; + +function prefixes(value: string): readonly string[] { + const words = value.split(" "); + const shown: string[] = []; + for ( + let count = WORDS_PER_FRAME; + count < words.length; + count += WORDS_PER_FRAME + ) { + shown.push(words.slice(0, count).join(" ")); + } + return shown; +} + +/** + * The forms a tool call takes before its final one. A call with an approval + * record first waits on a person and never shows as running; a call without + * one runs, then settles. + */ +function toolStages(part: PartTool): readonly PartTool[] { + if (part.approval !== undefined) { + if (part.approval.status === "pending") return []; + return [ + { + kind: "tool-trace", + toolCallId: part.toolCallId, + name: part.name, + status: "approval-requested", + input: part.input, + approval: { ...part.approval, status: "pending", scope: null }, + }, + ]; + } + if (part.status === "approval-requested" || part.status === "running") { + return []; + } + return [ + { + kind: "tool-trace", + toolCallId: part.toolCallId, + name: part.name, + status: "running", + input: part.input, + }, + ]; +} + +function imageStages(part: PartImage): readonly PartImage[] { + const [first] = part.images; + if (part.state !== "done" || first === undefined) return []; + return [ + { + kind: "image", + id: part.id, + prompt: part.prompt, + state: "generating", + images: [], + expected: { + count: part.images.length, + width: first.width, + height: first.height, + }, + }, + ]; +} + +/** The forms a part takes before its final one, oldest first. */ +function partStages(part: ConversationPart): readonly ConversationPart[] { + switch (part.kind) { + case "text": + return prefixes(part.text).map((shown) => ({ + kind: "text", + text: shown, + })); + case "reasoning": + // The duration is only known once the thinking is done. + return prefixes(part.text).map((shown) => ({ + kind: "reasoning", + text: shown, + })); + case "tool-trace": + return toolStages(part); + case "tool-batch": { + // Every call starts together, then they finish one at a time. + const started = part.tools.map((tool) => toolStages(tool)[0] ?? tool); + return part.tools.map((_, finished) => ({ + ...part, + tools: part.tools.map((tool, index) => + index < finished ? tool : (started[index] ?? tool), + ), + })); + } + case "image": + return imageStages(part); + default: + return []; + } +} + +/** + * Every frame of `message` arriving, from an empty streaming message to the + * message itself. The last frame is `message`, the same object. + */ +export function turnFrames( + message: ConversationMessage, +): readonly ConversationMessage[] { + const streaming = ( + parts: readonly ConversationPart[], + ): ConversationMessage => ({ + id: message.id, + author: message.author, + at: message.at, + status: "streaming", + parts, + }); + const frames = [streaming([])]; + message.parts.forEach((part, index) => { + const settled = message.parts.slice(0, index); + for (const stage of partStages(part)) { + frames.push(streaming([...settled, stage])); + } + if (index < message.parts.length - 1) { + frames.push(streaming(message.parts.slice(0, index + 1))); + } + }); + frames.push(message); + return frames; +} + +export type PlayTurnOptions = { + /** Milliseconds between frames. */ + readonly stepMs?: number; +}; + +/** + * Plays `message` in: calls `onFrame` with the first frame at once and each + * later frame `stepMs` apart, ending with `message` itself. Returns a stop + * function; after it is called no further frame is delivered. + */ +export function playTurn( + message: ConversationMessage, + onFrame: (frame: ConversationMessage) => void, + { stepMs = 60 }: PlayTurnOptions = {}, +): () => void { + const frames = turnFrames(message); + let next = 0; + let stopped = false; + let timer: ReturnType | undefined; + + const emit = () => { + const frame = frames[next]; + if (stopped || frame === undefined) return; + next += 1; + onFrame(frame); + // `onFrame` may itself call stop. + if (!stopped && next < frames.length) timer = setTimeout(emit, stepMs); + }; + emit(); + + return () => { + stopped = true; + clearTimeout(timer); + }; +} diff --git a/stories/chat/samples/samples.stories.tsx b/stories/chat/samples/samples.stories.tsx new file mode 100644 index 00000000..160e2134 --- /dev/null +++ b/stories/chat/samples/samples.stories.tsx @@ -0,0 +1,57 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../src/ui/table.js"; +import { samples } from "./conversations.js"; +import { turnFrames } from "./play-turn.js"; + +export default { title: "Chat / Samples" }; + +/** The shared sample conversations the chat stories draw on, and how big each is. */ +export const Sizes = () => ( +
+ + + Sample conversations. Frames counts the steps a streaming story plays + for the sample's last message. + + + + Sample + Messages + Parts + Thread replies + Frames + + + + {samples.map((sample) => { + const last = sample.messages.at(-1); + return ( + + + {sample.title} + + {sample.messages.length} + + {sample.messages.reduce( + (total, message) => total + message.parts.length, + 0, + )} + + {sample.thread?.replies.length ?? 0} + + {last === undefined ? 0 : turnFrames(last).length} + + + ); + })} + +
+
+); From d6f01f6718625fa6319b5b3785cb2a190b5b3d0f Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 27 Oct 2026 18:36:33 +0545 Subject: [PATCH 2/3] fix(stories): replay queued and stage-less tool calls faithfully The sample replay gave a queued call (pending, no approval record) a synthetic running frame, so it ran and then went back to pending. It is now shown queued from the start, like a waiting or running call. A tool batch also emitted one frame per call, so a call with no earlier form produced a frame identical to the one before it. The batch now finishes only the calls that change, one at a time, and leaves the rest in their final form, so every frame differs from the previous one. --- stories/chat/samples/play-turn.test.ts | 72 +++++++++++++++++++++++++- stories/chat/samples/play-turn.ts | 32 ++++++++---- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/stories/chat/samples/play-turn.test.ts b/stories/chat/samples/play-turn.test.ts index f820c149..44fbefd1 100644 --- a/stories/chat/samples/play-turn.test.ts +++ b/stories/chat/samples/play-turn.test.ts @@ -7,7 +7,12 @@ import type { PartTool, PartToolBatch, } from "../../../src/lib/conversation.js"; -import { assistant, generatedImages, toolStorm } from "./conversations.js"; +import { + assistant, + generatedImages, + samples, + toolStorm, +} from "./conversations.js"; import { playTurn, turnFrames } from "./play-turn.js"; function turn(parts: readonly ConversationPart[]): ConversationMessage { @@ -37,6 +42,20 @@ function lastPart(frame: ConversationMessage | undefined) { return frame?.parts.at(-1); } +function statusesOf(part: ConversationPart | undefined) { + return part?.kind === "tool-batch" + ? part.tools.map((call) => call.status) + : null; +} + +/** How many frames show exactly the parts of the frame before them. */ +function repeatedFrames(frames: readonly ConversationMessage[]): number { + return frames.filter( + (frame, index) => + index > 0 && Bun.deepEquals(frame.parts, frames[index - 1]?.parts), + ).length; +} + describe("turnFrames", () => { test("starts empty and streaming, and ends with the message itself", () => { const message = turn([{ kind: "text", text: "Done." }]); @@ -132,6 +151,57 @@ describe("turnFrames", () => { expect(new Set(statuses)).toEqual(new Set(["approval-requested"])); }); + test("shows a queued call in its final form at once, never as running", () => { + const queued = tool({ status: "pending", output: undefined }); + expect(turnFrames(turn([queued])).map(lastPart)).toEqual([ + undefined, + queued, + ]); + + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch", + tools: [tool({ toolCallId: "a" }), { ...queued, toolCallId: "b" }], + }; + const queuedStatuses = turnFrames(turn([batch])).flatMap((frame) => { + const part = lastPart(frame); + return part?.kind === "tool-batch" ? [part.tools[1]?.status] : []; + }); + expect(new Set(queuedStatuses)).toEqual(new Set(["pending"])); + }); + + test("never repeats a frame after a batch call that has no stage", () => { + const batch: PartToolBatch = { + kind: "tool-batch", + id: "batch", + tools: [ + tool({ toolCallId: "a" }), + tool({ toolCallId: "b", status: "running", output: undefined }), + tool({ toolCallId: "c" }), + tool({ toolCallId: "d", status: "running", output: undefined }), + ], + }; + const frames = turnFrames(turn([batch, { kind: "text", text: "ok" }])); + expect(repeatedFrames(frames)).toBe(0); + expect(frames.map((frame) => statusesOf(frame.parts[0]))).toEqual([ + null, + ["running", "running", "running", "running"], + ["output-available", "running", "running", "running"], + ["output-available", "running", "output-available", "running"], + ["output-available", "running", "output-available", "running"], + ]); + }); + + test("never repeats a frame in any sample message", () => { + const messages = samples.flatMap((sample) => [ + ...sample.messages, + ...(sample.thread?.replies ?? []), + ]); + for (const message of messages) { + expect(repeatedFrames(turnFrames(message))).toBe(0); + } + }); + test("starts every call in a batch together and finishes them one by one", () => { const batch: PartToolBatch = { kind: "tool-batch", diff --git a/stories/chat/samples/play-turn.ts b/stories/chat/samples/play-turn.ts index 697d0c2f..2216b803 100644 --- a/stories/chat/samples/play-turn.ts +++ b/stories/chat/samples/play-turn.ts @@ -30,7 +30,8 @@ function prefixes(value: string): readonly string[] { /** * The forms a tool call takes before its final one. A call with an approval * record first waits on a person and never shows as running; a call without - * one runs, then settles. + * one runs, then settles. A call still queued, waiting or running is already + * in its earliest form, so it has none. */ function toolStages(part: PartTool): readonly PartTool[] { if (part.approval !== undefined) { @@ -46,7 +47,11 @@ function toolStages(part: PartTool): readonly PartTool[] { }, ]; } - if (part.status === "approval-requested" || part.status === "running") { + if ( + part.status === "pending" || + part.status === "approval-requested" || + part.status === "running" + ) { return []; } return [ @@ -96,14 +101,21 @@ function partStages(part: ConversationPart): readonly ConversationPart[] { case "tool-trace": return toolStages(part); case "tool-batch": { - // Every call starts together, then they finish one at a time. - const started = part.tools.map((tool) => toolStages(tool)[0] ?? tool); - return part.tools.map((_, finished) => ({ - ...part, - tools: part.tools.map((tool, index) => - index < finished ? tool : (started[index] ?? tool), - ), - })); + // Every call starts together, then the ones that change finish one at + // a time; a call with no earlier form shows its final one throughout. + const starts = part.tools.flatMap((tool, index) => { + const [start] = toolStages(tool); + return start === undefined ? [] : [{ index, start }]; + }); + return starts.map((_, finished) => { + const unfinished = new Map( + starts.slice(finished).map(({ index, start }) => [index, start]), + ); + return { + ...part, + tools: part.tools.map((tool, index) => unfinished.get(index) ?? tool), + }; + }); } case "image": return imageStages(part); From 7873d583a0a24f38e1d91fe58346595bd2d29e5b Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 28 Oct 2026 19:36:03 +0545 Subject: [PATCH 3/3] docs(stories): say what the samples frames column counts The Samples table caption said Frames counts the steps a streaming story plays for the last message, but no story replays a sample's last message. It now says the column is how many steps turnFrames gives for that message. --- stories/chat/samples/samples.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stories/chat/samples/samples.stories.tsx b/stories/chat/samples/samples.stories.tsx index 160e2134..5593f33b 100644 --- a/stories/chat/samples/samples.stories.tsx +++ b/stories/chat/samples/samples.stories.tsx @@ -17,8 +17,8 @@ export const Sizes = () => (
- Sample conversations. Frames counts the steps a streaming story plays - for the sample's last message. + Sample conversations. Frames is how many steps turnFrames{" "} + gives for the sample's last message.