Files
oh-my-pi/packages/coding-agent/test/tools/irc.test.ts
T
can1357 bb4c9cae1e feat(coding-agent): added configurable IRC timeout with AbortSignal cancellation
- Added configurable IRC message timeout setting with 120-second default to prevent indefinite hangs.
- Implemented timeout enforcement for IRC send operations using AbortSignal-based cancellation.
- Modified Python tool bridge to route concurrent evaluations using per-run identifiers alongside session IDs.
- Enhanced test coverage for IRC timeout behavior, tool validation, and ephemeral cache key separation.
2026-05-26 14:56:49 +02:00

257 lines
9.4 KiB
TypeScript

import { beforeEach, describe, expect, it } from "bun:test";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { IrcTool } from "@oh-my-pi/pi-coding-agent/tools/irc";
interface FakeSession {
session: AgentSession;
calls: Array<{ from: string; message: string; awaitReply: boolean }>;
/** Override the reply this fake session generates. */
setReply: (text: string) => void;
/** Cause the next respondAsBackground call to throw. */
setError: (error: Error) => void;
/** Resolve the next respondAsBackground call only when allowed. */
gateNextCall: () => { release: () => void };
/** Keep the next respondAsBackground call pending until aborted. */
hangNextCall: () => void;
}
function makeFakeSession(): FakeSession {
let nextReply = "auto-reply";
let nextError: Error | null = null;
let gate: { promise: Promise<void>; release: () => void } | null = null;
let hangNext = false;
const calls: Array<{ from: string; message: string; awaitReply: boolean }> = [];
const session = {
respondAsBackground: async (args: {
from: string;
message: string;
awaitReply?: boolean;
signal?: AbortSignal;
}) => {
const awaitReply = args.awaitReply !== false;
calls.push({ from: args.from, message: args.message, awaitReply });
if (gate) {
const g = gate;
gate = null;
await g.promise;
}
if (hangNext) {
hangNext = false;
const deferred = Promise.withResolvers<never>();
if (args.signal?.aborted) {
deferred.reject(args.signal.reason instanceof Error ? args.signal.reason : new Error("aborted"));
} else {
args.signal?.addEventListener(
"abort",
() =>
deferred.reject(args.signal?.reason instanceof Error ? args.signal.reason : new Error("aborted")),
{ once: true },
);
}
return await deferred.promise;
}
if (nextError) {
const err = nextError;
nextError = null;
throw err;
}
return { replyText: awaitReply ? nextReply : null };
},
};
return {
session: session as unknown as AgentSession,
calls,
setReply: text => {
nextReply = text;
},
setError: error => {
nextError = error;
},
gateNextCall: () => {
const { promise, resolve } = Promise.withResolvers<void>();
gate = { promise, release: resolve };
return { release: resolve };
},
hangNextCall: () => {
hangNext = true;
},
};
}
function makeToolSession(registry: AgentRegistry, agentId: string): ToolSession {
return {
cwd: "/tmp",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
agentRegistry: registry,
getAgentId: () => agentId,
};
}
describe("IrcTool", () => {
let registry: AgentRegistry;
beforeEach(() => {
AgentRegistry.resetGlobalForTests();
registry = AgentRegistry.global();
});
it("createIf returns null when irc is disabled", () => {
const session: ToolSession = {
cwd: "/tmp",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
agentRegistry: registry,
getAgentId: () => "0-Main",
};
session.settings.set("irc.enabled", false);
expect(IrcTool.createIf(session)).toBeNull();
});
it("createIf returns null without registry/agentId", () => {
const session: ToolSession = {
cwd: "/tmp",
hasUI: false,
getSessionFile: () => null,
getSessionSpawns: () => "*",
settings: Settings.isolated(),
};
expect(IrcTool.createIf(session)).toBeNull();
});
it("op=list returns peers visible to the caller", async () => {
const main = makeFakeSession();
const sub = makeFakeSession();
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({
id: "0-AuthLoader",
displayName: "task",
kind: "sub",
parentId: "0-Main",
session: sub.session,
});
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const result = await tool.execute("call-1", { op: "list" });
expect(result.details?.op).toBe("list");
expect(result.details?.peers).toEqual([
{
id: "0-AuthLoader",
displayName: "task",
kind: "sub",
status: "running",
parentId: "0-Main",
},
]);
expect(result.details?.channels).toEqual(["all", "0-AuthLoader"]);
});
it("op=send DM returns the recipient's prose reply", async () => {
const main = makeFakeSession();
const sub = makeFakeSession();
sub.setReply("pong");
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({
id: "0-AuthLoader",
displayName: "task",
kind: "sub",
parentId: "0-Main",
session: sub.session,
});
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const result = await tool.execute("call-2", {
op: "send",
to: "0-AuthLoader",
message: "ping",
});
expect(result.details?.delivered).toEqual(["0-AuthLoader"]);
expect(result.details?.replies).toEqual([{ from: "0-AuthLoader", text: "pong" }]);
expect(sub.calls).toEqual([{ from: "0-Main", message: "ping", awaitReply: true }]);
});
it("op=send returns immediately even when the recipient is mid-tool-call", async () => {
// Simulate "blocked recipient": gateNextCall holds respondAsBackground
// pending until we release it. From the sender's perspective the call
// must still complete because the side-channel does not block on the
// recipient's main loop in a real session.
const main = makeFakeSession();
const sub = makeFakeSession();
sub.setReply("ok");
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({ id: "0-Busy", displayName: "task", kind: "sub", parentId: "0-Main", session: sub.session });
const gate = sub.gateNextCall();
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const pending = tool.execute("call-3", { op: "send", to: "0-Busy", message: "are you there?" });
// Release the gate after a microtask: the dispatch was already issued
// even though the recipient was holding.
setTimeout(() => gate.release(), 5);
const result = await pending;
expect(result.details?.delivered).toEqual(["0-Busy"]);
expect(result.details?.replies).toEqual([{ from: "0-Busy", text: "ok" }]);
});
it("op=send to=all broadcasts (default no reply, only injection on each peer)", async () => {
const main = makeFakeSession();
const subA = makeFakeSession();
const subB = makeFakeSession();
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({ id: "0-A", displayName: "task", kind: "sub", parentId: "0-Main", session: subA.session });
registry.register({ id: "0-B", displayName: "task", kind: "sub", parentId: "0-Main", session: subB.session });
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const result = await tool.execute("call-4", { op: "send", to: "all", message: "anyone there?" });
expect(new Set(result.details?.delivered)).toEqual(new Set(["0-A", "0-B"]));
expect(result.details?.replies ?? []).toEqual([]);
expect(subA.calls).toEqual([{ from: "0-Main", message: "anyone there?", awaitReply: false }]);
expect(subB.calls).toEqual([{ from: "0-Main", message: "anyone there?", awaitReply: false }]);
});
it("op=send returns notFound when target is unknown", async () => {
const main = makeFakeSession();
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const result = await tool.execute("call-5", { op: "send", to: "0-Ghost", message: "hi" });
expect(result.details?.delivered ?? []).toEqual([]);
expect(result.details?.notFound).toEqual(["0-Ghost"]);
});
it("op=send fails a hung recipient after the configured timeout", async () => {
const main = makeFakeSession();
const sub = makeFakeSession();
sub.hangNextCall();
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({ id: "0-Hung", displayName: "task", kind: "sub", parentId: "0-Main", session: sub.session });
const toolSession = makeToolSession(registry, "0-Main");
toolSession.settings.set("irc.timeoutMs", 5);
const tool = new IrcTool(toolSession);
const result = await tool.execute("call-timeout", { op: "send", to: "0-Hung", message: "ping" });
expect(result.details?.delivered ?? []).toEqual([]);
expect(result.details?.failed).toEqual([{ id: "0-Hung", error: "IRC timed out waiting for 0-Hung after 5 ms" }]);
expect(sub.calls).toEqual([{ from: "0-Main", message: "ping", awaitReply: true }]);
});
it("op=send surfaces recipient errors as failed", async () => {
const main = makeFakeSession();
const sub = makeFakeSession();
sub.setError(new Error("model unavailable"));
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
registry.register({ id: "0-Down", displayName: "task", kind: "sub", parentId: "0-Main", session: sub.session });
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
const result = await tool.execute("call-6", { op: "send", to: "0-Down", message: "ping" });
expect(result.details?.delivered ?? []).toEqual([]);
expect(result.details?.failed).toEqual([{ id: "0-Down", error: "model unavailable" }]);
});
});