Skip to content

Commit 9796dc0

Browse files
committed
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.
1 parent 140a383 commit 9796dc0

2 files changed

Lines changed: 93 additions & 11 deletions

File tree

‎stories/chat/samples/play-turn.test.ts‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import type {
77
PartTool,
88
PartToolBatch,
99
} from "../../../src/lib/conversation.js";
10-
import { assistant, generatedImages, toolStorm } from "./conversations.js";
10+
import {
11+
assistant,
12+
generatedImages,
13+
samples,
14+
toolStorm,
15+
} from "./conversations.js";
1116
import { playTurn, turnFrames } from "./play-turn.js";
1217

1318
function turn(parts: readonly ConversationPart[]): ConversationMessage {
@@ -37,6 +42,20 @@ function lastPart(frame: ConversationMessage | undefined) {
3742
return frame?.parts.at(-1);
3843
}
3944

45+
function statusesOf(part: ConversationPart | undefined) {
46+
return part?.kind === "tool-batch"
47+
? part.tools.map((call) => call.status)
48+
: null;
49+
}
50+
51+
/** How many frames show exactly the parts of the frame before them. */
52+
function repeatedFrames(frames: readonly ConversationMessage[]): number {
53+
return frames.filter(
54+
(frame, index) =>
55+
index > 0 && Bun.deepEquals(frame.parts, frames[index - 1]?.parts),
56+
).length;
57+
}
58+
4059
describe("turnFrames", () => {
4160
test("starts empty and streaming, and ends with the message itself", () => {
4261
const message = turn([{ kind: "text", text: "Done." }]);
@@ -132,6 +151,57 @@ describe("turnFrames", () => {
132151
expect(new Set(statuses)).toEqual(new Set(["approval-requested"]));
133152
});
134153

154+
test("shows a queued call in its final form at once, never as running", () => {
155+
const queued = tool({ status: "pending", output: undefined });
156+
expect(turnFrames(turn([queued])).map(lastPart)).toEqual([
157+
undefined,
158+
queued,
159+
]);
160+
161+
const batch: PartToolBatch = {
162+
kind: "tool-batch",
163+
id: "batch",
164+
tools: [tool({ toolCallId: "a" }), { ...queued, toolCallId: "b" }],
165+
};
166+
const queuedStatuses = turnFrames(turn([batch])).flatMap((frame) => {
167+
const part = lastPart(frame);
168+
return part?.kind === "tool-batch" ? [part.tools[1]?.status] : [];
169+
});
170+
expect(new Set(queuedStatuses)).toEqual(new Set(["pending"]));
171+
});
172+
173+
test("never repeats a frame after a batch call that has no stage", () => {
174+
const batch: PartToolBatch = {
175+
kind: "tool-batch",
176+
id: "batch",
177+
tools: [
178+
tool({ toolCallId: "a" }),
179+
tool({ toolCallId: "b", status: "running", output: undefined }),
180+
tool({ toolCallId: "c" }),
181+
tool({ toolCallId: "d", status: "running", output: undefined }),
182+
],
183+
};
184+
const frames = turnFrames(turn([batch, { kind: "text", text: "ok" }]));
185+
expect(repeatedFrames(frames)).toBe(0);
186+
expect(frames.map((frame) => statusesOf(frame.parts[0]))).toEqual([
187+
null,
188+
["running", "running", "running", "running"],
189+
["output-available", "running", "running", "running"],
190+
["output-available", "running", "output-available", "running"],
191+
["output-available", "running", "output-available", "running"],
192+
]);
193+
});
194+
195+
test("never repeats a frame in any sample message", () => {
196+
const messages = samples.flatMap((sample) => [
197+
...sample.messages,
198+
...(sample.thread?.replies ?? []),
199+
]);
200+
for (const message of messages) {
201+
expect(repeatedFrames(turnFrames(message))).toBe(0);
202+
}
203+
});
204+
135205
test("starts every call in a batch together and finishes them one by one", () => {
136206
const batch: PartToolBatch = {
137207
kind: "tool-batch",

‎stories/chat/samples/play-turn.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ function prefixes(value: string): readonly string[] {
3030
/**
3131
* The forms a tool call takes before its final one. A call with an approval
3232
* record first waits on a person and never shows as running; a call without
33-
* one runs, then settles.
33+
* one runs, then settles. A call still queued, waiting or running is already
34+
* in its earliest form, so it has none.
3435
*/
3536
function toolStages(part: PartTool): readonly PartTool[] {
3637
if (part.approval !== undefined) {
@@ -46,7 +47,11 @@ function toolStages(part: PartTool): readonly PartTool[] {
4647
},
4748
];
4849
}
49-
if (part.status === "approval-requested" || part.status === "running") {
50+
if (
51+
part.status === "pending" ||
52+
part.status === "approval-requested" ||
53+
part.status === "running"
54+
) {
5055
return [];
5156
}
5257
return [
@@ -96,14 +101,21 @@ function partStages(part: ConversationPart): readonly ConversationPart[] {
96101
case "tool-trace":
97102
return toolStages(part);
98103
case "tool-batch": {
99-
// Every call starts together, then they finish one at a time.
100-
const started = part.tools.map((tool) => toolStages(tool)[0] ?? tool);
101-
return part.tools.map((_, finished) => ({
102-
...part,
103-
tools: part.tools.map((tool, index) =>
104-
index < finished ? tool : (started[index] ?? tool),
105-
),
106-
}));
104+
// Every call starts together, then the ones that change finish one at
105+
// a time; a call with no earlier form shows its final one throughout.
106+
const starts = part.tools.flatMap((tool, index) => {
107+
const [start] = toolStages(tool);
108+
return start === undefined ? [] : [{ index, start }];
109+
});
110+
return starts.map((_, finished) => {
111+
const unfinished = new Map(
112+
starts.slice(finished).map(({ index, start }) => [index, start]),
113+
);
114+
return {
115+
...part,
116+
tools: part.tools.map((tool, index) => unfinished.get(index) ?? tool),
117+
};
118+
});
107119
}
108120
case "image":
109121
return imageStages(part);

0 commit comments

Comments
 (0)