fcb8663de8
Replaces the blocking auto-reply IRC turn with a process-global IrcBus and a four-op tool (send/wait/inbox/list). send is fire-and-forget with per-recipient delivery receipts (injected/woken/revived/failed); replies become real turns by the recipient, observed via wait (or the send await:true sugar). Bounded per-agent mailboxes (cap 100) drop oldest on overflow; AgentSession.deliverIrcMessage folds an in-flight delivery in as a non-interrupting aside at the next step boundary, or starts a real wake turn when idle. AgentRegistry/lifecycle handle the idle→woken / parked→revived transitions, so messaging a non-running peer brings it back. Adds a dedicated TUI renderer (directional headers, delivery-outcome coloring, quoted bodies, per-recipient receipt trees, status-badged peer lists with unread counts). irc.timeoutMs is now the default timeout for wait / send await:true. AgentSession sheds the agentRegistry config field, the dedupeIrcReply → dedupeEphemeralReply rename (now used by /btw and /omfg), and the background-channel exchange queue / forwardIrcRelayToMain plumbing the auto-reply model needed.
526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import { Agent } from "@oh-my-pi/pi-agent-core";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import { IrcBus, type IrcMessage } from "@oh-my-pi/pi-coding-agent/irc/bus";
|
|
import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle";
|
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
import { AgentSession, type AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
|
import type { CustomMessage } from "@oh-my-pi/pi-coding-agent/session/messages";
|
|
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
|
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;
|
|
/** Messages delivered into this session via deliverIrcMessage. */
|
|
delivered: IrcMessage[];
|
|
/** Outcome the fake reports (busy vs idle recipient). */
|
|
setOutcome: (outcome: "injected" | "woken") => void;
|
|
/** Cause the next deliverIrcMessage call to throw. */
|
|
setError: (error: Error) => void;
|
|
/** Side effect run on delivery (e.g. reply via the bus). */
|
|
onDeliver: (fn: (msg: IrcMessage) => void) => void;
|
|
}
|
|
|
|
function makeFakeSession(): FakeSession {
|
|
let outcome: "injected" | "woken" = "injected";
|
|
let nextError: Error | null = null;
|
|
let deliverHook: ((msg: IrcMessage) => void) | undefined;
|
|
const delivered: IrcMessage[] = [];
|
|
const session = {
|
|
deliverIrcMessage: async (msg: IrcMessage) => {
|
|
if (nextError) {
|
|
const err = nextError;
|
|
nextError = null;
|
|
throw err;
|
|
}
|
|
delivered.push(msg);
|
|
deliverHook?.(msg);
|
|
return outcome;
|
|
},
|
|
emitIrcRelayObservation: () => {},
|
|
};
|
|
return {
|
|
session: session as unknown as AgentSession,
|
|
delivered,
|
|
setOutcome: value => {
|
|
outcome = value;
|
|
},
|
|
setError: error => {
|
|
nextError = error;
|
|
},
|
|
onDeliver: fn => {
|
|
deliverHook = fn;
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeToolSession(registry: AgentRegistry, agentId: string): ToolSession {
|
|
return {
|
|
cwd: "/tmp",
|
|
hasUI: false,
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
settings: Settings.isolated(),
|
|
agentRegistry: registry,
|
|
getAgentId: () => agentId,
|
|
};
|
|
}
|
|
|
|
function createRealSession(): { session: AgentSession; sessionManager: SessionManager } {
|
|
const sessionManager = SessionManager.inMemory("/tmp");
|
|
const session = new AgentSession({
|
|
agent: new Agent({
|
|
initialState: {
|
|
systemPrompt: ["system prompt"],
|
|
messages: [],
|
|
tools: [],
|
|
},
|
|
}),
|
|
sessionManager,
|
|
settings: Settings.isolated({ "compaction.enabled": false }),
|
|
modelRegistry: {} as never,
|
|
});
|
|
return { session, sessionManager };
|
|
}
|
|
|
|
describe("IRC", () => {
|
|
let registry: AgentRegistry;
|
|
let bus: IrcBus;
|
|
|
|
const sessions: AgentSession[] = [];
|
|
beforeEach(() => {
|
|
AgentRegistry.resetGlobalForTests();
|
|
AgentLifecycleManager.resetGlobalForTests();
|
|
IrcBus.resetGlobalForTests();
|
|
registry = AgentRegistry.global();
|
|
bus = IrcBus.global();
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
for (const session of sessions.splice(0)) {
|
|
await session.dispose();
|
|
}
|
|
});
|
|
|
|
describe("IrcBus", () => {
|
|
it("send delivers to a live recipient and reports the session outcome", async () => {
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
sub.setOutcome("injected");
|
|
const injected = await bus.send({ from: "0-Main", to: "0-Sub", body: "ping" });
|
|
expect(injected).toEqual({ to: "0-Sub", outcome: "injected" });
|
|
|
|
sub.setOutcome("woken");
|
|
const woken = await bus.send({ from: "0-Main", to: "0-Sub", body: "ping again" });
|
|
expect(woken.outcome).toBe("woken");
|
|
|
|
expect(sub.delivered.map(msg => msg.body)).toEqual(["ping", "ping again"]);
|
|
expect(sub.delivered[0]?.from).toBe("0-Main");
|
|
expect(sub.delivered[0]?.id).toBeTruthy();
|
|
});
|
|
|
|
it("send to an unknown or aborted agent fails", async () => {
|
|
const unknown = await bus.send({ from: "0-Main", to: "0-Ghost", body: "hello?" });
|
|
expect(unknown.outcome).toBe("failed");
|
|
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Dead", displayName: "task", kind: "sub", session: sub.session });
|
|
registry.setStatus("0-Dead", "aborted");
|
|
const aborted = await bus.send({ from: "0-Main", to: "0-Dead", body: "hello?" });
|
|
expect(aborted.outcome).toBe("failed");
|
|
});
|
|
|
|
it("send surfaces recipient delivery errors as failed", async () => {
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
sub.setError(new Error("boom"));
|
|
const receipt = await bus.send({ from: "0-Main", to: "0-Sub", body: "ping" });
|
|
expect(receipt).toEqual({ to: "0-Sub", outcome: "failed", error: "boom" });
|
|
});
|
|
|
|
it("send revives a parked recipient through the lifecycle manager", async () => {
|
|
const sub = makeFakeSession();
|
|
sub.setOutcome("woken");
|
|
registry.register({ id: "0-Parked", displayName: "task", kind: "sub", session: null, status: "parked" });
|
|
AgentLifecycleManager.global().adopt("0-Parked", {
|
|
idleTtlMs: 0,
|
|
revive: async () => sub.session,
|
|
});
|
|
|
|
const receipt = await bus.send({ from: "0-Main", to: "0-Parked", body: "wake up" });
|
|
expect(receipt.outcome).toBe("revived");
|
|
expect(sub.delivered.map(msg => msg.body)).toEqual(["wake up"]);
|
|
expect(registry.get("0-Parked")?.status).toBe("idle");
|
|
});
|
|
|
|
it("send fails cleanly when a parked recipient has no reviver", async () => {
|
|
registry.register({ id: "0-Parked", displayName: "task", kind: "sub", session: null, status: "parked" });
|
|
AgentLifecycleManager.global().adopt("0-Parked", { idleTtlMs: 0 });
|
|
const receipt = await bus.send({ from: "0-Main", to: "0-Parked", body: "wake up" });
|
|
expect(receipt.outcome).toBe("failed");
|
|
expect(receipt.error).toBeTruthy();
|
|
});
|
|
|
|
it("wait consumes a matching send instead of delivering it to the session", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
const waiting = bus.wait("0-Main", { from: "0-Sub" }, 1000);
|
|
const receipt = await bus.send({ from: "0-Sub", to: "0-Main", body: "pong" });
|
|
expect(receipt.outcome).toBe("injected");
|
|
|
|
const msg = await waiting;
|
|
expect(msg?.body).toBe("pong");
|
|
// The waiter consumed the message: no session delivery, no inbox copy.
|
|
expect(main.delivered).toEqual([]);
|
|
expect(bus.unreadCount("0-Main")).toBe(0);
|
|
});
|
|
|
|
it("wait from-filter ignores messages from other senders", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const a = makeFakeSession();
|
|
registry.register({ id: "0-A", displayName: "task", kind: "sub", session: a.session });
|
|
const b = makeFakeSession();
|
|
registry.register({ id: "0-B", displayName: "task", kind: "sub", session: b.session });
|
|
|
|
const waiting = bus.wait("0-Main", { from: "0-B" }, 1000);
|
|
await bus.send({ from: "0-A", to: "0-Main", body: "not for the waiter" });
|
|
// The non-matching message fell through to normal delivery.
|
|
expect(main.delivered.map(msg => msg.body)).toEqual(["not for the waiter"]);
|
|
|
|
await bus.send({ from: "0-B", to: "0-Main", body: "for the waiter" });
|
|
const msg = await waiting;
|
|
expect(msg?.from).toBe("0-B");
|
|
expect(msg?.body).toBe("for the waiter");
|
|
});
|
|
|
|
it("wait returns null on timeout and rejects on abort", async () => {
|
|
// Genuine 5ms wall-clock timeout: this deliberately exercises the
|
|
// bus's real timer path; nothing else races it.
|
|
expect(await bus.wait("0-Main", {}, 5)).toBeNull();
|
|
|
|
const controller = new AbortController();
|
|
const waiting = bus.wait("0-Main", {}, 1000, controller.signal);
|
|
controller.abort(new Error("cancelled"));
|
|
await expect(waiting).rejects.toThrow("cancelled");
|
|
});
|
|
|
|
it("wait drains an already-pending mailbox message first", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "earlier" });
|
|
expect(bus.unreadCount("0-Main")).toBe(1);
|
|
|
|
// Resolves from the mailbox synchronously; the timeout never fires.
|
|
const msg = await bus.wait("0-Main", { from: "0-Sub" }, 5);
|
|
expect(msg?.body).toBe("earlier");
|
|
expect(bus.unreadCount("0-Main")).toBe(0);
|
|
});
|
|
|
|
it("inbox peeks or drains pending messages", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "one" });
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "two" });
|
|
|
|
const peeked = bus.inbox("0-Main", { peek: true });
|
|
expect(peeked.map(msg => msg.body)).toEqual(["one", "two"]);
|
|
expect(bus.unreadCount("0-Main")).toBe(2);
|
|
|
|
const drained = bus.inbox("0-Main");
|
|
expect(drained.map(msg => msg.body)).toEqual(["one", "two"]);
|
|
expect(bus.unreadCount("0-Main")).toBe(0);
|
|
expect(bus.inbox("0-Main")).toEqual([]);
|
|
});
|
|
|
|
it("wait does not leak the waiter after timeout or abort", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
// Timed-out waiter is removed: a later send goes to normal delivery.
|
|
expect(await bus.wait("0-Main", {}, 5)).toBeNull();
|
|
const afterTimeout = await bus.send({ from: "0-Sub", to: "0-Main", body: "after timeout" });
|
|
expect(afterTimeout.outcome).toBe("injected");
|
|
expect(main.delivered.map(msg => msg.body)).toEqual(["after timeout"]);
|
|
expect(bus.unreadCount("0-Main")).toBe(1);
|
|
bus.inbox("0-Main");
|
|
|
|
// Aborted waiter is removed too: the dead waiter never consumes mail.
|
|
const controller = new AbortController();
|
|
const waiting = bus.wait("0-Main", {}, 1000, controller.signal);
|
|
controller.abort(new Error("cancelled"));
|
|
await expect(waiting).rejects.toThrow("cancelled");
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "after abort" });
|
|
expect(main.delivered.map(msg => msg.body)).toEqual(["after timeout", "after abort"]);
|
|
expect(bus.unreadCount("0-Main")).toBe(1);
|
|
});
|
|
|
|
it("resolves waiters in FIFO order", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
const first = bus.wait("0-Main", {}, 1000);
|
|
const second = bus.wait("0-Main", {}, 1000);
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "one" });
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "two" });
|
|
|
|
expect((await first)?.body).toBe("one");
|
|
expect((await second)?.body).toBe("two");
|
|
// Both messages were consumed by waiters, none reached the session.
|
|
expect(main.delivered).toEqual([]);
|
|
expect(bus.unreadCount("0-Main")).toBe(0);
|
|
});
|
|
|
|
it("mailbox drops the oldest message beyond the 100-message cap", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
for (let i = 0; i <= 100; i++) {
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: `msg-${i}` });
|
|
}
|
|
|
|
expect(bus.unreadCount("0-Main")).toBe(100);
|
|
const pending = bus.inbox("0-Main", { peek: true });
|
|
expect(pending[0]?.body).toBe("msg-1");
|
|
expect(pending[pending.length - 1]?.body).toBe("msg-100");
|
|
});
|
|
|
|
it("send surfaces the reviver's error message when revival fails", async () => {
|
|
registry.register({ id: "0-Parked", displayName: "task", kind: "sub", session: null, status: "parked" });
|
|
AgentLifecycleManager.global().adopt("0-Parked", {
|
|
idleTtlMs: 0,
|
|
revive: async () => {
|
|
throw new Error("revive exploded");
|
|
},
|
|
});
|
|
|
|
const receipt = await bus.send({ from: "0-Main", to: "0-Parked", body: "wake up" });
|
|
expect(receipt).toEqual({ to: "0-Parked", outcome: "failed", error: "revive exploded" });
|
|
// Failed revival never enqueues: the message is lost, not buffered.
|
|
expect(bus.unreadCount("0-Parked")).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("IrcTool", () => {
|
|
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 includes parked peers, unread counts, and parent ids", async () => {
|
|
const sub = makeFakeSession();
|
|
registry.register({
|
|
id: "0-AuthLoader",
|
|
displayName: "task",
|
|
kind: "sub",
|
|
parentId: "0-Main",
|
|
session: sub.session,
|
|
});
|
|
registry.register({ id: "0-Parked", displayName: "task", kind: "sub", session: null, status: "parked" });
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
await bus.send({ from: "0-Main", to: "0-AuthLoader", body: "unread one" });
|
|
|
|
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).toMatchObject([
|
|
{ id: "0-AuthLoader", status: "running", parentId: "0-Main", unread: 1 },
|
|
{ id: "0-Parked", status: "parked", unread: 0 },
|
|
]);
|
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
expect(text).toContain("Parked agents are revived automatically");
|
|
});
|
|
|
|
it("op=send returns receipts immediately without waiting for a reply", async () => {
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", { op: "send", to: "0-Sub", message: "ping" });
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.details?.receipts).toEqual([{ to: "0-Sub", outcome: "injected" }]);
|
|
expect(result.details?.waited).toBeUndefined();
|
|
expect(sub.delivered.map(msg => msg.body)).toEqual(["ping"]);
|
|
});
|
|
|
|
it("op=send to=all fans out to live peers and reports per-recipient receipts", async () => {
|
|
const a = makeFakeSession();
|
|
registry.register({ id: "0-A", displayName: "task", kind: "sub", session: a.session });
|
|
const b = makeFakeSession();
|
|
b.setError(new Error("kaput"));
|
|
registry.register({ id: "0-B", displayName: "task", kind: "sub", session: b.session });
|
|
registry.register({ id: "0-Parked", displayName: "task", kind: "sub", session: null, status: "parked" });
|
|
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", { op: "send", to: "all", message: "anyone there?" });
|
|
// Broadcast skips parked agents; one failure does not block the other delivery.
|
|
expect(result.details?.receipts).toEqual([
|
|
{ to: "0-A", outcome: "injected" },
|
|
{ to: "0-B", outcome: "failed", error: "kaput" },
|
|
]);
|
|
expect(a.delivered.map(msg => msg.body)).toEqual(["anyone there?"]);
|
|
});
|
|
|
|
it("op=send await=true round-trips the recipient's reply", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
sub.onDeliver(msg => {
|
|
// Reply synchronously during delivery: it lands in the sender's
|
|
// mailbox, which the tool's await-wait drains deterministically.
|
|
void bus.send({ from: "0-Sub", to: msg.from, body: "pong", replyTo: msg.id });
|
|
});
|
|
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", { op: "send", to: "0-Sub", message: "ping", await: true });
|
|
expect(result.details?.waited?.body).toBe("pong");
|
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
expect(text).toContain("pong");
|
|
});
|
|
|
|
it("op=send await=true reports a clean timeout when no reply arrives", async () => {
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", {
|
|
op: "send",
|
|
to: "0-Sub",
|
|
message: "ping",
|
|
// Real 5ms timeout — exercises the timeout path; no reply ever arrives.
|
|
await: true,
|
|
timeoutMs: 5,
|
|
});
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.details?.waited).toBeNull();
|
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
expect(text).toContain("No reply from 0-Sub");
|
|
});
|
|
|
|
it("op=send rejects await with to=all and self-sends", async () => {
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const broadcast = await tool.execute("call-1", { op: "send", to: "all", message: "x", await: true });
|
|
expect(broadcast.isError).toBe(true);
|
|
const self = await tool.execute("call-2", { op: "send", to: "0-Main", message: "x" });
|
|
expect(self.isError).toBe(true);
|
|
});
|
|
|
|
it("op=send returns a failed receipt for unknown targets", async () => {
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", { op: "send", to: "0-Ghost", message: "ping" });
|
|
expect(result.isError).toBe(true);
|
|
expect(result.details?.receipts?.[0]?.outcome).toBe("failed");
|
|
});
|
|
|
|
it("op=wait returns a clean non-error timeout result", async () => {
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const result = await tool.execute("call-1", { op: "wait", timeoutMs: 5 });
|
|
expect(result.isError).toBeFalsy();
|
|
expect(result.details?.waited).toBeNull();
|
|
const text = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
expect(text).toContain("No message");
|
|
});
|
|
|
|
it("op=inbox drains the caller's mailbox", async () => {
|
|
const main = makeFakeSession();
|
|
registry.register({ id: "0-Main", displayName: "main", kind: "main", session: main.session });
|
|
const sub = makeFakeSession();
|
|
registry.register({ id: "0-Sub", displayName: "task", kind: "sub", session: sub.session });
|
|
await bus.send({ from: "0-Sub", to: "0-Main", body: "fyi" });
|
|
|
|
const tool = new IrcTool(makeToolSession(registry, "0-Main"));
|
|
const peeked = await tool.execute("call-1", { op: "inbox", peek: true });
|
|
expect(peeked.details?.inbox?.map(msg => msg.body)).toEqual(["fyi"]);
|
|
const drained = await tool.execute("call-2", { op: "inbox" });
|
|
expect(drained.details?.inbox?.map(msg => msg.body)).toEqual(["fyi"]);
|
|
const empty = await tool.execute("call-3", { op: "inbox" });
|
|
expect(empty.details?.inbox).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("AgentSession.deliverIrcMessage", () => {
|
|
it("wakes an idle session with a real turn and emits the irc_message event", async () => {
|
|
const { session } = createRealSession();
|
|
sessions.push(session);
|
|
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
|
|
const ircEvent = new Promise<AgentSessionEvent>(resolve => {
|
|
session.subscribe(event => {
|
|
if (event.type === "irc_message") resolve(event);
|
|
});
|
|
});
|
|
|
|
const outcome = await session.deliverIrcMessage({
|
|
id: "msg-1",
|
|
from: "0-Peer",
|
|
to: "0-Me",
|
|
body: "wake up",
|
|
ts: Date.now(),
|
|
});
|
|
expect(outcome).toBe("woken");
|
|
expect(promptSpy).toHaveBeenCalledTimes(1);
|
|
const prompted = promptSpy.mock.calls[0]?.[0] as unknown as CustomMessage;
|
|
expect(prompted).toMatchObject({ role: "custom", customType: "irc:incoming" });
|
|
expect(prompted.details).toMatchObject({ id: "msg-1", from: "0-Peer", message: "wake up" });
|
|
|
|
const event = await ircEvent;
|
|
expect(event.type).toBe("irc_message");
|
|
});
|
|
|
|
it("queues a non-interrupting aside when a turn is streaming", async () => {
|
|
const { session } = createRealSession();
|
|
sessions.push(session);
|
|
const promptSpy = vi.spyOn(session.agent, "prompt").mockResolvedValue(undefined);
|
|
Object.defineProperty(session, "isStreaming", { value: true, configurable: true });
|
|
|
|
const outcome = await session.deliverIrcMessage({
|
|
id: "msg-2",
|
|
from: "0-Peer",
|
|
to: "0-Me",
|
|
body: "mid-turn note",
|
|
ts: Date.now(),
|
|
});
|
|
expect(outcome).toBe("injected");
|
|
expect(promptSpy).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|