assistant-stream
Version:
Streaming utilities for AI assistants
533 lines (434 loc) • 17.7 kB
text/typescript
import { describe, expect, it, vi } from "vitest";
import { ToolCallArgsReaderImpl, ToolCallReaderImpl } from "./ToolCallReader";
const parsePartialJsonObjectCalls = vi.hoisted(() => vi.fn());
vi.mock(
"../../utils/json/parse-partial-json-object",
async (importOriginal) => {
const original =
await importOriginal<
typeof import("../../utils/json/parse-partial-json-object")
>();
return {
...original,
parsePartialJsonObject: (
...args: Parameters<typeof original.parsePartialJsonObject>
) => {
parsePartialJsonObjectCalls(...args);
return original.parsePartialJsonObject(...args);
},
};
},
);
type Args = {
required: string;
optional?: string;
items?: string[];
};
const createReader = () => new ToolCallReaderImpl<Args, string>();
const collect = async <T>(stream: AsyncIterable<T>) => {
const values: T[] = [];
for await (const value of stream) values.push(value);
return values;
};
describe("ToolCallArgsReader parsing", () => {
it("does not parse streamed arguments without an active reader", async () => {
parsePartialJsonObjectCalls.mockClear();
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"');
await reader.appendArgsTextDelta("hello");
await reader.appendArgsTextDelta('"}');
await reader.finishArgsText();
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalled();
});
it("parses accumulated arguments when a reader starts", async () => {
parsePartialJsonObjectCalls.mockClear();
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hel');
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalled();
const streamReader = reader.args.streamText("required").getReader();
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalledWith(
'{"required":"hel',
);
await expect(streamReader.read()).resolves.toEqual({
done: false,
value: "hel",
});
await reader.appendArgsTextDelta('lo"}');
await reader.finishArgsText();
let value = "hel";
while (true) {
const next = await streamReader.read();
if (next.done) break;
value += next.value;
}
expect(value).toBe("hello");
});
it("stops parsing after the last reader settles", async () => {
parsePartialJsonObjectCalls.mockClear();
const reader = createReader();
const required = reader.args.get("required");
await reader.appendArgsTextDelta('{"required":"hello",');
expect(await required).toBe("hello");
expect(parsePartialJsonObjectCalls).toHaveBeenCalledTimes(2);
await reader.appendArgsTextDelta('"optional":"later"}');
await reader.finishArgsText();
expect(parsePartialJsonObjectCalls).toHaveBeenCalledTimes(2);
});
it("parses completed arguments once for late readers", async () => {
parsePartialJsonObjectCalls.mockClear();
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hello"}');
await reader.finishArgsText();
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalled();
await expect(reader.args.get("required")).resolves.toBe("hello");
await expect(reader.args.get("required")).resolves.toBe("hello");
expect(
parsePartialJsonObjectCalls.mock.calls.filter(
([text]) => text === '{"required":"hello"}',
),
).toHaveLength(1);
});
it("stops parsing after a reader is cancelled", async () => {
parsePartialJsonObjectCalls.mockClear();
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hel');
const streamReader = reader.args.streamText("required").getReader();
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalledWith(
'{"required":"hel',
);
await streamReader.cancel();
parsePartialJsonObjectCalls.mockClear();
await reader.appendArgsTextDelta('lo"}');
await reader.finishArgsText();
expect(parsePartialJsonObjectCalls).not.toHaveBeenCalled();
});
it("does not emit stale values for an unparseable delta", async () => {
const reader = createReader();
const stream = reader.args.streamValues("required");
await reader.appendArgsTextDelta('{"required":"hi');
await reader.appendArgsTextDelta("\\uZZ");
await reader.finishArgsText();
const values: string[] = [];
for await (const value of stream) values.push(value ?? "");
expect(values).toEqual(["hi"]);
});
});
describe("ToolCallArgsReader.get", () => {
it.each(["constructor", "toString"] as const)(
"does not inherit a missing %s field before or after completion",
async (key) => {
const reader = new ToolCallReaderImpl<
{ constructor?: string; toString?: string },
string
>();
const pending = reader.args.get(key);
await reader.appendArgsTextDelta("{}");
await reader.finishArgsText();
expect(await pending).toBeUndefined();
expect(await reader.args.get(key)).toBeUndefined();
},
);
it("checks own properties at every nested path segment", async () => {
const reader = new ToolCallReaderImpl<
{
nested: { toString?: string; constructor?: { name: string } };
},
string
>();
await reader.appendArgsTextDelta('{"nested":{}}');
await reader.finishArgsText();
expect(await reader.args.get("nested", "toString")).toBeUndefined();
expect(
await reader.args.get("nested", "constructor", "name"),
).toBeUndefined();
});
it.each(["constructor", "toString"] as const)(
"preserves an explicitly supplied own %s field",
async (key) => {
const reader = new ToolCallReaderImpl<
{ constructor?: string; toString?: string },
string
>();
const pending = reader.args.get(key);
await reader.appendArgsTextDelta(
JSON.stringify({ [key]: "actual value" }),
);
await reader.finishArgsText();
expect(await pending).toBe("actual value");
expect(await reader.args.get(key)).toBe("actual value");
},
);
it("waits for all digits of a positive exponent", async () => {
const reader = new ToolCallReaderImpl<{ amount: number }, string>();
const amount = reader.args.get("amount");
await reader.appendArgsTextDelta('{"amount":1e+2');
await reader.appendArgsTextDelta("3}");
await reader.finishArgsText();
expect(await amount).toBe(1e23);
});
it("resolves with the value once the field is complete", async () => {
const reader = createReader();
const promise = reader.args.get("required");
await reader.appendArgsTextDelta('{"required":"hello"}');
expect(await promise).toBe("hello");
});
it("resolves to undefined for an absent field once args close", async () => {
const reader = createReader();
const promise = reader.args.get("optional");
await reader.appendArgsTextDelta('{"required":"hello"}');
await reader.finishArgsText();
// Previously this never resolved and deadlocked the tool.
expect(await promise).toBeUndefined();
});
it("resolves to undefined for a field requested after args close", async () => {
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hello"}');
await reader.finishArgsText();
expect(await reader.args.get("optional")).toBeUndefined();
expect(await reader.args.get("required")).toBe("hello");
});
it("does not deadlock awaiting an optional arg inside a side effect", async () => {
const reader = createReader();
const sideEffect = (async () => {
const optional = await reader.args.get("optional");
return optional ?? "fallback";
})();
await reader.appendArgsTextDelta('{"required":"hello"}');
await reader.finishArgsText();
expect(await sideEffect).toBe("fallback");
});
});
describe("ToolCallArgsReader streams", () => {
it.each([
["unicode escape", "\\u0061", ["", "a"]],
["newline escape", "\\n", ["", "\n"]],
["plain text", "hi", ["", "h", "i"]],
["empty text", "", [""]],
] as const)(
"streams %s without redundant empty deltas and closes with the field",
async (_name, encoded, expected) => {
const reader = createReader();
const deltas = collect(reader.args.streamText("required"));
await reader.appendArgsTextDelta('{"required":"');
for (const character of encoded) {
await reader.appendArgsTextDelta(character);
}
await reader.appendArgsTextDelta('"');
expect(await deltas).toEqual(expected);
await reader.appendArgsTextDelta(',"optional":"later"}');
await reader.finishArgsText();
},
);
it.each(["", "hello"])(
"streams a completed field to late subscribers (%j)",
async (text) => {
const reader = createReader();
await reader.appendArgsTextDelta(JSON.stringify({ required: text }));
await reader.finishArgsText();
expect(await collect(reader.args.streamText("required"))).toEqual([text]);
},
);
it.each([
["streamText", '{"required":"first', ' second"}', " second"],
["streamValues", '{"required":"first', ' second"}', "first second"],
["forEach", '{"items":["first",', '"second"]}', "second"],
] as const)(
"keeps other %s subscribers active when one is cancelled",
async (method, initial, rest, next) => {
const reader = createReader();
const subscribe = () =>
method === "forEach"
? reader.args.forEach("items").getReader()
: reader.args[method]("required").getReader();
const first = subscribe();
const cancelled = subscribe();
const last = subscribe();
await reader.appendArgsTextDelta(initial);
for (const subscriber of [first, cancelled, last]) {
expect(await subscriber.read()).toEqual({
done: false,
value: "first",
});
}
await cancelled.cancel();
await reader.appendArgsTextDelta(rest);
await reader.finishArgsText();
expect(await cancelled.read()).toEqual({ done: true, value: undefined });
for (const subscriber of [first, last]) {
expect(await subscriber.read()).toEqual({ done: false, value: next });
expect(await subscriber.read()).toEqual({
done: true,
value: undefined,
});
}
},
);
it("does not stream inherited values while arguments are partial or complete", async () => {
const reader = new ToolCallReaderImpl<
{ toString?: string; nested: { toString?: string } },
string
>();
const values = reader.args.streamValues("toString");
const nestedValues = reader.args.streamValues("nested", "toString");
await reader.appendArgsTextDelta('{"nested":{');
await reader.appendArgsTextDelta("}}");
await reader.finishArgsText();
expect(await collect(values)).toEqual([]);
expect(await collect(nestedValues)).toEqual([]);
expect(await collect(reader.args.streamValues("toString"))).toEqual([]);
});
it("streams an explicitly supplied nested prototype-named field", async () => {
const reader = new ToolCallReaderImpl<
{ nested: { toString: string } },
string
>();
const values = reader.args.streamValues("nested", "toString");
await reader.appendArgsTextDelta('{"nested":{"toString":"hel');
await reader.appendArgsTextDelta('lo"}}');
await reader.finishArgsText();
expect(await collect(values)).toEqual(["hel", "hello"]);
});
it("closes streamValues when args close without the field", async () => {
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hello"}');
await reader.finishArgsText();
const seen: unknown[] = [];
for await (const value of reader.args.streamValues("items")) {
seen.push(value);
}
expect(seen).toEqual([]);
});
it("emits completed array items and closes via forEach", async () => {
const reader = createReader();
await reader.appendArgsTextDelta('{"required":"hi","items":["a","b"]}');
await reader.finishArgsText();
const seen: string[] = [];
for await (const item of reader.args.forEach("items")) {
seen.push(item);
}
expect(seen).toEqual(["a", "b"]);
});
});
describe("ToolCallArgsReader.forEach lifecycle", () => {
it("waits for nested partial elements and emits them once in order", async () => {
const reader = new ToolCallReaderImpl<
{ items: { label: string; tags: string[] }[] },
string
>();
const values = reader.args.forEach("items").getReader();
const first = values.read();
let settled = false;
void first.then(() => {
settled = true;
});
await reader.appendArgsTextDelta('{"items":[{"label":"hel');
await reader.appendArgsTextDelta('lo","tags":["a');
await reader.appendArgsTextDelta('b"]');
expect(settled).toBe(false);
await reader.appendArgsTextDelta('},{"label":"second","tags":[');
expect(await first).toEqual({
done: false,
value: { label: "hello", tags: ["ab"] },
});
await reader.appendArgsTextDelta("]}]}");
await reader.finishArgsText();
expect(await values.read()).toEqual({
done: false,
value: { label: "second", tags: [] },
});
expect(await values.read()).toEqual({ done: true, value: undefined });
});
it("starts late with completed entries and retains the partial tail", async () => {
const reader = new ToolCallReaderImpl<{ items: string[] }, string>();
await reader.appendArgsTextDelta('{"items":["first","sec');
const values = reader.args.forEach("items");
await reader.appendArgsTextDelta('ond"]}');
await reader.finishArgsText();
expect(await collect(values)).toEqual(["first", "second"]);
expect(await collect(reader.args.forEach("items"))).toEqual([
"first",
"second",
]);
});
it.each(['{"items":[]}', "{}"])(
"closes without items for %s",
async (json) => {
const reader = new ToolCallReaderImpl<{ items?: string[] }, string>();
const values = reader.args.forEach("items");
await reader.appendArgsTextDelta(json);
await reader.finishArgsText();
expect(await collect(values)).toEqual([]);
},
);
it("does not emit a partial trailing element when the stream ends", async () => {
const reader = new ToolCallReaderImpl<{ items: string[] }, string>();
const values = reader.args.forEach("items");
await reader.appendArgsTextDelta('{"items":["first","unfinished');
await reader.finishArgsText();
expect(await collect(values)).toEqual(["first"]);
});
});
describe("ToolCallArgsReader termination", () => {
const failure = new Error("connection reset");
const argsReader = (deltas: string[], reason?: unknown) => {
let index = 0;
return new ToolCallArgsReaderImpl<Args>(
// Erroring a controller discards its queue, so the deltas are pulled one
// at a time to reach the reader before the failure.
new ReadableStream<string>({
pull(controller) {
const delta = deltas[index++];
if (delta !== undefined) {
controller.enqueue(delta);
} else if (reason === undefined) {
controller.close();
} else {
controller.error(reason);
}
},
}),
);
};
it("rejects a pending get when the args stream fails mid-payload", async () => {
const args = argsReader(['{"required":"hel'], failure);
await expect(args.get("required")).rejects.toBe(failure);
});
it("rejects a get requested after the args stream failed", async () => {
const args = argsReader(['{"required":"hel'], failure);
await expect(args.get("required")).rejects.toBe(failure);
await expect(args.get("required")).rejects.toBe(failure);
});
it("keeps a field that completed before the failure", async () => {
const args = argsReader(['{"required":"hello","optional":"par'], failure);
await expect(args.get("required")).resolves.toBe("hello");
await expect(args.get("optional")).rejects.toBe(failure);
});
// The first get drives the stream to its failure, so the second goes through
// activateHandle rather than the in-flight path.
it("keeps a completed field for a get issued after the failure", async () => {
const args = argsReader(['{"required":"hello","optional":"par'], failure);
await expect(args.get("optional")).rejects.toBe(failure);
await expect(args.get("required")).resolves.toBe("hello");
await expect(args.get("items")).rejects.toBe(failure);
});
it("resolves undefined for an absent field when the stream closes cleanly", async () => {
const args = argsReader(['{"required":"hello"}']);
await expect(args.get("optional")).resolves.toBeUndefined();
});
it("resolves the value when the stream closes cleanly", async () => {
const args = argsReader(['{"required":"hel', 'lo"}']);
await expect(args.get("required")).resolves.toBe("hello");
});
it("errors an open streamText when the args stream fails", async () => {
const args = argsReader(['{"required":"hel'], failure);
await expect(collect(args.streamText("required"))).rejects.toBe(failure);
});
it("errors an open streamValues when the args stream fails", async () => {
const args = argsReader(['{"required":"hel'], failure);
await expect(collect(args.streamValues("required"))).rejects.toBe(failure);
});
it("errors an open forEach when the args stream fails", async () => {
const args = argsReader(['{"required":"hi","items":["a"'], failure);
await expect(collect(args.forEach("items"))).rejects.toBe(failure);
});
});