import test from "node:test"; import assert from "node:assert/strict"; import { getAskTimeoutMs } from "./config.ts"; import { ReplyTracker } from "./reply-tracker.ts"; import type { Message, SessionInfo } from "./types.ts"; function createSession(id: string, name: string): SessionInfo { return { id, name, cwd: "/tmp/project", model: "test-model", pid: 1, startedAt: 1, lastActivity: 1, }; } function createMessage(id: string, text: string, expectsReply = true): Message { return { id, timestamp: 1, expectsReply, content: { text }, }; } test("reply resolves from current triggered message context", () => { const tracker = new ReplyTracker(); const from = createSession("planner-id", "planner"); const message = createMessage("ask-1", "Need a decision"); const context = tracker.recordIncomingMessage(from, message, 1000); tracker.queueTurnContext(context); tracker.beginTurn(1001); assert.equal(tracker.resolveReplyTarget({}, 1002).message.id, "ask-1"); assert.equal(tracker.resolveReplyTarget({}, 1002).from.id, "planner-id"); }); test("reply resolves from single pending ask without current turn context", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "Need a decision"), 1000); assert.equal(tracker.resolveReplyTarget({}, 1001).message.id, "ask-1"); }); test("reply with to resolves matching pending ask", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "First"), 1000); tracker.recordIncomingMessage(createSession("reviewer-id", "reviewer"), createMessage("ask-2", "Second"), 1001); assert.equal(tracker.resolveReplyTarget({ to: "reviewer" }, 1002).message.id, "ask-2"); assert.equal(tracker.resolveReplyTarget({ to: "planner-id" }, 1002).message.id, "ask-1"); }); test("explicit to overrides the current turn context", () => { const tracker = new ReplyTracker(); const current = tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "First"), 1000); tracker.recordIncomingMessage(createSession("reviewer-id", "reviewer"), createMessage("ask-2", "Second"), 1001); tracker.queueTurnContext(current); tracker.beginTurn(1002); assert.equal(tracker.resolveReplyTarget({ to: "reviewer" }, 1003).message.id, "ask-2"); assert.throws(() => tracker.resolveReplyTarget({ to: "missing" }, 1003), /No pending ask from/); }); test("replyTo resolves the exact pending ask", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "First"), 1000); tracker.recordIncomingMessage(createSession("reviewer-id", "reviewer"), createMessage("ask-2", "Second"), 1001); assert.equal(tracker.resolveReplyTarget({ replyTo: "ask-2" }, 1002).from.id, "reviewer-id"); assert.throws(() => tracker.resolveReplyTarget({ to: "planner", replyTo: "ask-2" }, 1002), /is not from/); }); test("reply errors when no context and no pending asks", () => { const tracker = new ReplyTracker(); assert.throws(() => tracker.resolveReplyTarget({}, 1000), /No active intercom context to reply to/); }); test("reply errors when multiple pending asks and no to", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "First"), 1000); tracker.recordIncomingMessage(createSession("reviewer-id", "reviewer"), createMessage("ask-2", "Second"), 1001); assert.throws(() => tracker.resolveReplyTarget({}, 1002), /Multiple pending asks — specify `to`/); }); test("reply removes pending ask after successful reply", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "Need a decision"), 1000); tracker.markReplied("ask-1"); assert.deepEqual(tracker.listPending(1001), []); }); test("ask timeout can be configured from environment", () => { const previous = process.env.PI_INTERCOM_ASK_TIMEOUT_MS; process.env.PI_INTERCOM_ASK_TIMEOUT_MS = "42"; try { assert.equal(getAskTimeoutMs(), 42); assert.throws(() => { process.env.PI_INTERCOM_ASK_TIMEOUT_MS = "0"; getAskTimeoutMs(); }, /positive integer/); } finally { if (previous === undefined) delete process.env.PI_INTERCOM_ASK_TIMEOUT_MS; else process.env.PI_INTERCOM_ASK_TIMEOUT_MS = previous; } }); test("pending asks can be explicitly dismissed without removing retryable failures", () => { const tracker = new ReplyTracker(); tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "Need a decision"), 1000); tracker.recordIncomingMessage(createSession("reviewer-id", "reviewer"), createMessage("ask-2", "Retryable"), 1001); tracker.dismissPendingAsk("ask-1"); assert.deepEqual(tracker.listPending(1002).map((context) => context.message.id), ["ask-2"]); }); test("dismissing a pending ask removes queued turn context", () => { const tracker = new ReplyTracker(); const context = tracker.recordIncomingMessage(createSession("planner-id", "planner"), createMessage("ask-1", "Need a decision"), 1000); tracker.queueTurnContext(context); tracker.dismissPendingAsk("ask-1"); tracker.beginTurn(1001); assert.throws(() => tracker.resolveReplyTarget({}, 1002), /No active intercom context to reply to/); });