mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
- ai: StreamingJsonParser / ToolCallArgumentsStream read each argument delta once (tolerant, immutable snapshots, linear cost); every provider, pi-messages and the faux provider use it and keep stream state (partialJson, partialArgs, index, customInput, streamIndex, Bedrock reasoning bytes) off the content blocks; grammar tool input appends per delta - agent: streamProxy uses the same parser - chord: Delta append ops own their text (ownedSlice), so ops and replicas no longer keep the whole growing string alive - durable, ai, mcp: bounded error excerpts and summaries own their characters - coding-agent: tool calls render streamed arguments once per frame, not once per delta
171 lines
6.1 KiB
TypeScript
171 lines
6.1 KiB
TypeScript
import type { AssistantMessage, AssistantMessageEvent, Model } from "@earendil-works/pi-ai";
|
|
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { type ProxyAssistantMessageEvent, streamProxy } from "../src/proxy.ts";
|
|
|
|
const model: Model<"openai-responses"> = {
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4",
|
|
api: "openai-responses",
|
|
provider: "openai",
|
|
baseUrl: "https://api.openai.com/v1",
|
|
reasoning: true,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 400000,
|
|
maxTokens: 128000,
|
|
};
|
|
|
|
const usage: AssistantMessage["usage"] = {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("streamProxy", () => {
|
|
it("preserves tool-call metadata received only on toolcall_end", async () => {
|
|
const proxyEvents: ProxyAssistantMessageEvent[] = [
|
|
{ type: "start" },
|
|
{ type: "toolcall_start", contentIndex: 0, id: "call_test|fc_test", toolName: "lookup" },
|
|
{ type: "toolcall_delta", contentIndex: 0, delta: '{"value":"hello"}' },
|
|
{
|
|
type: "toolcall_end",
|
|
contentIndex: 0,
|
|
toolCall: {
|
|
type: "toolCall",
|
|
id: "call_test|fc_test",
|
|
name: "lookup",
|
|
arguments: { value: "hello" },
|
|
namespace: "dynamic_tools",
|
|
},
|
|
},
|
|
{ type: "done", reason: "toolUse", usage },
|
|
];
|
|
const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(body, { status: 200 })),
|
|
);
|
|
|
|
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
|
authToken: "test-token",
|
|
proxyUrl: "https://proxy.example.com",
|
|
});
|
|
const events: AssistantMessageEvent[] = [];
|
|
for await (const event of stream) events.push(event);
|
|
const result = await stream.result();
|
|
const endEvent = events.find((event) => event.type === "toolcall_end");
|
|
|
|
expect(endEvent).toMatchObject({
|
|
type: "toolcall_end",
|
|
toolCall: { namespace: "dynamic_tools" },
|
|
});
|
|
expect(result.content[0]).toMatchObject({
|
|
type: "toolCall",
|
|
arguments: { value: "hello" },
|
|
namespace: "dynamic_tools",
|
|
});
|
|
});
|
|
|
|
// Regression tests for https://github.com/earendil-works/pi/issues/8996
|
|
it("processes terminal metadata when the event is not newline-terminated", async () => {
|
|
const start = `data: ${JSON.stringify({ type: "start" })}\n\n`;
|
|
const done = `data: ${JSON.stringify({ type: "done", reason: "stop", usage, providerThinkingLevel: "high" })}`;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(start + done, { status: 200 })),
|
|
);
|
|
|
|
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
|
authToken: "test-token",
|
|
proxyUrl: "https://proxy.example.com",
|
|
});
|
|
const events: AssistantMessageEvent[] = [];
|
|
for await (const event of stream) events.push(event);
|
|
const result = await stream.result();
|
|
|
|
expect(events.map((event) => event.type)).toEqual(["start", "done"]);
|
|
expect(result.stopReason).toBe("stop");
|
|
expect(result.providerThinkingLevel).toBe("high");
|
|
});
|
|
|
|
it("emits an error instead of hanging when the stream ends without a terminal event", async () => {
|
|
const body = `data: ${JSON.stringify({ type: "start" })}\n\n`;
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(body, { status: 200 })),
|
|
);
|
|
|
|
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
|
authToken: "test-token",
|
|
proxyUrl: "https://proxy.example.com",
|
|
});
|
|
const events: AssistantMessageEvent[] = [];
|
|
for await (const event of stream) events.push(event);
|
|
const result = await stream.result();
|
|
|
|
expect(events.map((event) => event.type)).toEqual(["start", "error"]);
|
|
expect(result.stopReason).toBe("error");
|
|
expect(result.errorMessage).toContain("Connection closed by proxy server");
|
|
});
|
|
|
|
// #9265
|
|
it("shows growing partial arguments without keeping the raw text on the block, in linear time", async () => {
|
|
const content = 'line "quoted" \\ end\n'.repeat(20_000);
|
|
const text = JSON.stringify({ path: "a.ts", content });
|
|
const proxyEvents: ProxyAssistantMessageEvent[] = [
|
|
{ type: "start" },
|
|
{ type: "toolcall_start", contentIndex: 0, id: "call_1", toolName: "write" },
|
|
];
|
|
for (let index = 0; index < text.length; index += 32) {
|
|
proxyEvents.push({ type: "toolcall_delta", contentIndex: 0, delta: text.slice(index, index + 32) });
|
|
}
|
|
proxyEvents.push(
|
|
{
|
|
type: "toolcall_end",
|
|
contentIndex: 0,
|
|
toolCall: { type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } },
|
|
},
|
|
{ type: "done", reason: "toolUse", usage },
|
|
);
|
|
const body = proxyEvents.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("");
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response(body, { status: 200 })),
|
|
);
|
|
|
|
const started = performance.now();
|
|
const stream = streamProxy(model, normalizeContext({ systemPrompt: "", messages: [] }), {
|
|
authToken: "test-token",
|
|
proxyUrl: "https://proxy.example.com",
|
|
});
|
|
let shown = 0;
|
|
let deltas = 0;
|
|
for await (const event of stream) {
|
|
if (event.type !== "toolcall_delta") continue;
|
|
const block = event.partial.content[0];
|
|
expect(Object.keys(block!).sort()).toEqual(["arguments", "id", "name", "type"]);
|
|
const partial = block?.type === "toolCall" ? block.arguments.content : undefined;
|
|
if (typeof partial !== "string") continue;
|
|
// Comparing the whole text on every delta would make this test itself quadratic.
|
|
if (deltas++ % 500 === 0) expect(content.startsWith(partial)).toBe(true);
|
|
expect(partial.length).toBeGreaterThanOrEqual(shown);
|
|
shown = partial.length;
|
|
}
|
|
const result = await stream.result();
|
|
|
|
expect(shown).toBeGreaterThan(content.length - 64);
|
|
expect(result.content).toEqual([
|
|
{ type: "toolCall", id: "call_1", name: "write", arguments: { path: "a.ts", content } },
|
|
]);
|
|
// Re-parsing the accumulated 520 KB of arguments on each of its 16,000 deltas took minutes.
|
|
expect(performance.now() - started).toBeLessThan(10_000);
|
|
});
|
|
});
|