import { createServer } from "node:http"; import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { launchVerificationServer, runControlOmb } from "../scripts/control-omb.ts"; import { STOPPED_TURN_NAME } from "../shared/client-cancel.ts"; import { unaskedStopMessage } from "./harness/unasked-stop.ts"; import { roomRetryRequest } from "../src/lib/room-retry.ts"; const CANCELLED = "The request was cancelled by the client."; const PARTIAL = "Partial answer before the stop."; type ChatMessage = { role: string; content?: unknown; tool_call_id?: string }; type ChatRequest = { messages: ChatMessage[]; tools?: Array<{ function: { name: string } }> }; const frame = (delta: unknown, finish_reason: string | null) => `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason }] })}\n\n`; const lastUser = (request: ChatRequest) => { const content = request.messages.filter((message) => message.role === "user").at(-1)?.content; return typeof content === "string" ? content : JSON.stringify(content ?? ""); }; it("says in words that a turn nobody stopped was cut off, and retries the room request a client abort stopped", async () => { const requests: ChatRequest[] = []; const upstream = createServer(async (req, res) => { if (req.url === "/v1/models") { res.setHeader("content-type", "application/json"); res.end(JSON.stringify({ data: [{ id: "fixture-model" }] })); return; } // The chat engine asks a local server whether it is Ollama (/api/version); this one is not. if (req.method === "GET") { res.statusCode = 404; res.end(); return; } let body = ""; for await (const chunk of req) body += chunk; const request = JSON.parse(body) as ChatRequest; requests.push(request); const user = lastUser(request); res.writeHead(200, { "content-type": "text/event-stream" }); // A room prompt can quote the bot's other work, so room lines go first. if (user.includes("Say hello first.") && !user.includes("Read the brief.")) { res.end(frame({ content: "Hello." }, "stop") + "data: [DONE]\n\n"); return; } if (user.includes("Read the brief.")) { res.end(frame({ content: CANCELLED }, "stop") + "data: [DONE]\n\n"); return; } if (user.includes("Use the tool, then stop.")) { if (request.messages.some((message) => message.role === "tool")) { // The second round is aborted by the client, as some gateways say. res.end(`data: ${JSON.stringify({ error: { message: CANCELLED } })}\n\n`); return; } // A harmless read of the harness's own team list: like every agents // tool it asks nothing, and the turn goes on. res.end(frame({ content: PARTIAL, tool_calls: [{ index: 0, id: "look-1", type: "function", function: { name: "agents_list_bots", arguments: "{}" } }] }, "tool_calls") + "data: [DONE]\n\n"); return; } res.end(frame({ content: "Done." }, "stop") + "data: [DONE]\n\n"); }); await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve)); const address = upstream.address(); if (!address || typeof address === "string") throw new Error("fixture provider address missing"); const fixture = await launchVerificationServer().catch(async (error) => { upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); throw error; }); const control = (args: string[]) => runControlOmb([...args, "--url", fixture.info.url]) as Promise; const api = async (method: string, path: string, body?: unknown) => { const response = await fetch(`${fixture.info.url}${path}`, { method, headers: { "content-type": "application/json", origin: fixture.info.url }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), }); const result = await response.json() as any; expect(response.ok, JSON.stringify(result)).toBe(true); return result; }; const messages = async (threadId: string) => (await api("GET", `/api/threads/${threadId}/messages?limit=100`)).messages as any[]; try { await api("PATCH", "/api/config", { openaiCompat: { key: "synthetic-fixture-key", url: `http://127.0.0.1:${address.port}/v1`, model: "fixture-model" } }); const { bot } = await control(["new-bot", "--name", "Stopper"]); await control(["set-model", "--bot", bot.id, "--instance", "openaiCompat", "--model", "fixture-model"]); await api("PATCH", `/api/bots/${bot.id}`, { notifications: true }); expect((await control(["send", "--bot", bot.id, "--task", bot.activeTaskId, "--text", "Use the tool, then stop."])).success).toBe(true); await control(["wait", "--bot", bot.id, "--task", bot.activeTaskId, "--timeout", "30"]); const digest = await vi.waitFor(async () => { const found = (await messages(bot.activeTaskId)).find((message) => message.kind === "digest"); if (!found) throw new Error("waiting for the turn digest"); return found; }, { timeout: 10_000, interval: 100 }); const direct = await messages(bot.activeTaskId); expect(direct.filter((message) => message.tool?.name === STOPPED_TURN_NAME)).toHaveLength(0); const failed = direct.filter((message) => message.kind === "activity" && message.tool?.ok === false); expect(failed.map((message) => message.tool.name)).toEqual([`error: ${unaskedStopMessage(CANCELLED)}`]); expect(direct.some((message) => message.kind === "text" && message.text === PARTIAL)).toBe(true); expect(direct.some((message) => message.card)).toBe(false); expect(digest.digest.reply).toBe(PARTIAL); const { group } = await api("POST", "/api/groups", { name: "Stop room", memberIds: [bot.id], setup: { bulletin: "", defaultResponder: { kind: "member", botId: bot.id } }, }); await control(["send-channel", "--channel", group.id, "--text", "Say hello first."]); expect((await control(["wait", "--channel", group.id, "--timeout", "30"])).status).toBe("settled"); const brief = join(fixture.info.dataDir, "brief.txt"); writeFileSync(brief, "fixture brief"); const request = `Read the brief.\n\n`; await api("POST", `/api/groups/${group.id}/messages`, { text: request, threadId: group.threadId }); await control(["wait", "--channel", group.id, "--timeout", "30"]); const room = await messages(group.threadId); const stoppedIndex = room.findIndex((message) => message.tool?.name === STOPPED_TURN_NAME); expect(stoppedIndex).toBeGreaterThan(0); expect(room.some((message) => message.kind === "text" && message.text === CANCELLED)).toBe(false); const sent = room.find((message) => message.role === "user" && message.text?.includes("Read the brief.")); const retry = roomRetryRequest(room, stoppedIndex); expect(retry).toMatchObject({ messageId: sent.id, mode: "chat" }); expect(retry!.text).toContain("Read the brief."); expect(retry!.text).toContain(`path="${brief}"`); expect(retry!.text).not.toContain("Say hello first."); const before = requests.length; await api("POST", `/api/groups/${group.id}/messages`, { text: retry!.text, threadId: group.threadId, mode: retry!.mode }); await control(["wait", "--channel", group.id, "--timeout", "30"]); const resent = lastUser(requests.slice(before).at(-1)!); expect(resent).toContain("Read the brief."); expect(resent).toContain("brief.txt"); } finally { await fixture.close(); upstream.closeAllConnections(); await new Promise((resolve) => upstream.close(() => resolve())); } }, 180_000);