* Stop two web E2E tests from racing on main Replies read themselves aloud by default since the voice update, so in the group chat test the last bot's automatic read-aloud could beat the manual Speak click. The test turns automatic reading off for its two bots. Opening the browser panel on a running computer now only fetches the screen, as it did before the panel change, instead of also reloading the whole thread on every computer state change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Wait for the speaking switch to settle in the voice test The app turns the local voice on by itself on first start and keeps the switch disabled meanwhile. The helper read "off" during that window and clicked a disabled switch; it now retries until the switch is on, clicking only when it is enabled and off. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Fix main after the git-clone push: lint, i18n and abort tests - Lint: formatting in two files and no assignment inside an expression in the steering poller. - i18n: German for the new "Resume" and "Stopped" labels on web, and de/ru/zh on mobile; catalogs re-extracted. - Tests follow the intended change that a caller abort ends a turn as "stopped" instead of failing it as a provider error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Record runtime failures again instead of leaving the run running A runtime error closes the stream through withRuntimeCleanup, which aborts the run's own controller. The catch treated that as an outside stop and returned without finalizing, so a failed run stayed running forever and its delegated task never reported back. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 --------- Co-authored-by: Claude <noreply@anthropic.com>
285 lines
9.3 KiB
TypeScript
285 lines
9.3 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { AgentRunRequest, AgentRuntimeEvent, ConnectorTool } from "@rakazo/adapter-kit";
|
|
import { PiAgentRuntime } from "@rakazo/adapters";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { type ModelEmulatorRequest, startModelEmulator } from "./model-emulator.js";
|
|
|
|
const cleanups: Array<() => Promise<void>> = [];
|
|
afterEach(async () => {
|
|
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
|
});
|
|
|
|
const writeTool: ConnectorTool = {
|
|
name: "write_file",
|
|
description: "Save a UTF-8 file",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
required: ["path", "content"],
|
|
additionalProperties: false,
|
|
},
|
|
};
|
|
|
|
function runRequest(
|
|
model: AgentRunRequest["model"],
|
|
overrides: Partial<AgentRunRequest> = {},
|
|
): AgentRunRequest {
|
|
return {
|
|
botId: "fixture-bot",
|
|
threadId: "fixture-thread",
|
|
runId: randomUUID(),
|
|
prompt: "Save hello to notes.txt.",
|
|
instructions: "Complete the requested task.",
|
|
history: [],
|
|
tools: [writeTool],
|
|
model,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
async function collect(stream: AsyncIterable<AgentRuntimeEvent>, events: AgentRuntimeEvent[] = []) {
|
|
for await (const event of stream) events.push(event);
|
|
return events;
|
|
}
|
|
|
|
function latestToolResult(request: ModelEmulatorRequest) {
|
|
return request.messages.findLast((message) => message.role === "tool");
|
|
}
|
|
|
|
describe("real Pi against an offline model HTTP endpoint", () => {
|
|
it("assembles fragmented tool arguments, executes the write, and sends its result back", async () => {
|
|
const dir = await mkdtemp(path.join(tmpdir(), "rakazo-pi-offline-"));
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }));
|
|
const calls: Array<{ name: string; args: Record<string, unknown>; id?: string }> = [];
|
|
const server = await startModelEmulator({
|
|
apiKey: "fixture-only-key",
|
|
steps: [
|
|
{
|
|
expect(request) {
|
|
expect(request.messages.at(-1)).toMatchObject({
|
|
role: "user",
|
|
content: [{ type: "text", text: "Save hello to notes.txt." }],
|
|
});
|
|
expect(request.tools).toContainEqual(
|
|
expect.objectContaining({
|
|
function: expect.objectContaining({ name: "write_file" }),
|
|
}),
|
|
);
|
|
},
|
|
response: {
|
|
type: "tool",
|
|
id: "write-1",
|
|
name: "write_file",
|
|
arguments: { path: "notes.txt", content: "hello\n" },
|
|
argumentChunks: ['{"pa', 'th":"notes.', 'txt","content":"hel', "lo\\", 'n"}'],
|
|
},
|
|
},
|
|
{
|
|
expect(request) {
|
|
expect(
|
|
request.messages.find((message) => message.role === "assistant")?.tool_calls,
|
|
).toEqual([
|
|
{
|
|
id: "write-1",
|
|
type: "function",
|
|
function: {
|
|
name: "write_file",
|
|
arguments: JSON.stringify({ path: "notes.txt", content: "hello\n" }),
|
|
},
|
|
},
|
|
]);
|
|
expect(latestToolResult(request)).toMatchObject({ tool_call_id: "write-1" });
|
|
expect(JSON.parse(String(latestToolResult(request)?.content))).toEqual({
|
|
path: "notes.txt",
|
|
bytes: 6,
|
|
});
|
|
},
|
|
response: { type: "text", text: "Saved notes.txt." },
|
|
},
|
|
],
|
|
});
|
|
cleanups.push(() => server.close());
|
|
const events = await collect(
|
|
new PiAgentRuntime().run(
|
|
runRequest(server.model, {
|
|
async executeTool(name, args, id) {
|
|
calls.push({ name, args, id });
|
|
expect(name).toBe("write_file");
|
|
expect(args.path).toBe("notes.txt");
|
|
await writeFile(path.join(dir, "notes.txt"), String(args.content));
|
|
return { path: "notes.txt", bytes: Buffer.byteLength(String(args.content)) };
|
|
},
|
|
}),
|
|
),
|
|
).catch((error) => {
|
|
try {
|
|
server.assertComplete();
|
|
} catch (fixtureError) {
|
|
throw new AggregateError(
|
|
[error, fixtureError],
|
|
"Pi failed and model fixture validation also failed",
|
|
);
|
|
}
|
|
throw error;
|
|
});
|
|
server.assertComplete();
|
|
expect(calls).toEqual([
|
|
{ name: "write_file", args: { path: "notes.txt", content: "hello\n" }, id: "write-1" },
|
|
]);
|
|
expect(await readFile(path.join(dir, "notes.txt"), "utf8")).toBe("hello\n");
|
|
expect(events.at(-1)).toEqual({ type: "done", text: "Saved notes.txt." });
|
|
});
|
|
|
|
it("returns tool failure to the model and continues without claiming a successful write", async () => {
|
|
let calls = 0;
|
|
const server = await startModelEmulator({
|
|
steps: [
|
|
{
|
|
expect() {},
|
|
response: {
|
|
type: "tool",
|
|
id: "denied-write",
|
|
name: "write_file",
|
|
arguments: { path: "notes.txt", content: "hello" },
|
|
},
|
|
},
|
|
{
|
|
expect(request) {
|
|
expect(latestToolResult(request)).toMatchObject({ tool_call_id: "denied-write" });
|
|
expect(String(latestToolResult(request)?.content)).toContain("Fixture write denied");
|
|
},
|
|
response: { type: "text", text: "The file could not be saved." },
|
|
},
|
|
],
|
|
});
|
|
cleanups.push(() => server.close());
|
|
const events = await collect(
|
|
new PiAgentRuntime().run(
|
|
runRequest(server.model, {
|
|
executeTool: async () => {
|
|
calls++;
|
|
throw new Error("Fixture write denied");
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
server.assertComplete();
|
|
expect(calls).toBe(1);
|
|
expect(events.at(-1)).toEqual({ type: "done", text: "The file could not be saved." });
|
|
});
|
|
|
|
it("propagates a provider rejection without dispatching tools or emitting done", async () => {
|
|
let calls = 0;
|
|
const server = await startModelEmulator({
|
|
steps: [
|
|
{
|
|
expect() {},
|
|
response: { type: "error", status: 400, message: "Fixture request rejected" },
|
|
},
|
|
],
|
|
});
|
|
cleanups.push(() => server.close());
|
|
const events: AgentRuntimeEvent[] = [];
|
|
await expect(
|
|
collect(
|
|
new PiAgentRuntime().run(
|
|
runRequest(server.model, {
|
|
executeTool: async () => {
|
|
calls++;
|
|
return {};
|
|
},
|
|
}),
|
|
),
|
|
events,
|
|
),
|
|
).rejects.toThrow(/Fixture request rejected/);
|
|
server.assertComplete();
|
|
expect(calls).toBe(0);
|
|
expect(events.some((event) => event.type === "done")).toBe(false);
|
|
});
|
|
|
|
it("rejects an interrupted SSE response without emitting successful completion", async () => {
|
|
const server = await startModelEmulator({
|
|
steps: [{ expect() {}, response: { type: "disconnect", text: "Working" } }],
|
|
});
|
|
cleanups.push(() => server.close());
|
|
const events: AgentRuntimeEvent[] = [];
|
|
await expect(
|
|
collect(new PiAgentRuntime().run(runRequest(server.model)), events),
|
|
).rejects.toThrow();
|
|
server.assertComplete();
|
|
expect(events.some((event) => event.type === "done")).toBe(false);
|
|
});
|
|
|
|
it("cancels a quiet HTTP stream and closes the provider connection", async () => {
|
|
const opened = deferred();
|
|
const closed = deferred();
|
|
const server = await startModelEmulator({
|
|
steps: [
|
|
{
|
|
expect() {},
|
|
response: {
|
|
type: "hold",
|
|
onOpen: opened.resolve,
|
|
onClose: closed.resolve,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
cleanups.push(() => server.close());
|
|
const controller = new AbortController();
|
|
const events: AgentRuntimeEvent[] = [];
|
|
const work = collect(
|
|
new PiAgentRuntime().run(runRequest(server.model), { signal: controller.signal }),
|
|
events,
|
|
);
|
|
await opened.promise;
|
|
controller.abort();
|
|
// A caller abort ends the turn as stopped, not as a provider error.
|
|
await work;
|
|
await closed.promise;
|
|
server.assertComplete();
|
|
expect(events.filter((event) => event.type === "done")).toEqual([
|
|
{ type: "done", text: "stopped" },
|
|
]);
|
|
});
|
|
|
|
it("keeps two model connections and their responses isolated", async () => {
|
|
const first = await startModelEmulator({
|
|
modelId: "fixture-first",
|
|
apiKey: "first-fixture-key",
|
|
steps: [{ expect() {}, response: { type: "text", text: "First connection" } }],
|
|
});
|
|
const second = await startModelEmulator({
|
|
modelId: "fixture-second",
|
|
apiKey: "second-fixture-key",
|
|
steps: [{ expect() {}, response: { type: "text", text: "Second connection" } }],
|
|
});
|
|
cleanups.push(
|
|
() => first.close(),
|
|
() => second.close(),
|
|
);
|
|
const runtime = new PiAgentRuntime();
|
|
const results = await Promise.all(
|
|
[first, second].map((server) => collect(runtime.run(runRequest(server.model)))),
|
|
);
|
|
expect(results.map((events) => events.at(-1))).toEqual([
|
|
{ type: "done", text: "First connection" },
|
|
{ type: "done", text: "Second connection" },
|
|
]);
|
|
first.assertComplete();
|
|
second.assertComplete();
|
|
});
|
|
});
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|