import { expect, test } from "bun:test"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; function runtime(state: "missing" | "enqueued" | "delivered", active = false) { const calls: unknown[] = []; const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { active: async () => active ? { s: {} } : {}, interrupt: async (input: unknown) => { calls.push(input); } } }), agentMessageState: async () => state, prompt: async (...args: unknown[]) => { calls.push(args); }, }) as OpencodeRuntimeAdapter; return { adapter, calls }; } test("host reply continuation injects fixed ID only when absent; queued and consumed IDs never reinject", async () => { const missing = runtime("missing"); expect(await missing.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("started"); expect(missing.calls).toEqual([["s", "answer", undefined, "msg_fixed"]]); const busy = runtime("missing", true); await expect(busy.adapter.continueInteraction("s", "msg_fixed", "answer")).rejects.toThrow("still active"); expect(busy.calls).toEqual([]); const enqueued = runtime("enqueued"); expect(await enqueued.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("observing"); expect(enqueued.calls).toEqual([{ sessionID: "s", resume: true }]); const consumed = runtime("delivered"); expect(await consumed.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("completed"); expect(consumed.calls).toEqual([]); const observing = runtime("delivered", true); expect(await observing.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("observing"); expect(observing.calls).toEqual([]); }); test("native question lookup distinguishes missing form from unavailable service", async () => { let failure: unknown = { _tag: "FormNotFoundError" }; const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }), }) as OpencodeRuntimeAdapter; expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("missing"); failure = new Error("network unavailable"); await expect(adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable"); failure = undefined; expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("answered"); }); test("message admission lookup fails closed on inbox and message transport errors", async () => { let inboxFailure = true; let messageFailure: unknown = { status: 404 }; const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { inbox: { list: async () => { if (inboxFailure) throw new Error("inbox unavailable"); return []; } }, message: { get: async () => { throw messageFailure; } } } }), }) as OpencodeRuntimeAdapter; await expect(adapter.agentMessageState("s", "msg_fixed")).rejects.toThrow("inbox unavailable"); inboxFailure = false; expect(await adapter.agentMessageState("s", "msg_fixed")).toBe("missing"); messageFailure = new Error("message unavailable"); await expect(adapter.agentMessageState("s", "msg_fixed")).rejects.toThrow("message unavailable"); }); test("stop serializes behind in-flight answer admission and cancels its exact inbox identity", async () => { let release!: () => void; let admitted!: () => void; const entered = new Promise((resolve) => { admitted = resolve; }); const barrier = new Promise((resolve) => { release = resolve; }); let state: "missing" | "enqueued" = "missing"; const calls: string[] = []; const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { active: async () => ({}), inbox: { cancel: async (input: { inboxID: string }) => { calls.push(`cancel:${input.inboxID}`); state = "missing"; } } } }), agentMessageState: async () => state, prompt: async (_sessionId: string, _text: string, _model: unknown, messageId: string) => { calls.push(`admit:${messageId}`); admitted(); await barrier; state = "enqueued"; }, }) as OpencodeRuntimeAdapter; const continuation = adapter.continueInteraction("s", "msg_fixed", "answer"); await entered; const stopping = adapter.stopInteractionInput("s", "msg_fixed"); await Promise.resolve(); expect(calls).toEqual(["admit:msg_fixed"]); release(); expect(await continuation).toBe("started"); expect(await stopping).toBe("cancelled"); expect(calls).toEqual(["admit:msg_fixed", "cancel:msg_fixed"]); }); test("completed-answer recovery uses the matching user boundary and refuses missing final evidence", async () => { let messages: unknown[] = [ { id: "later-answer", type: "assistant", time: { completed: 1 }, finish: "stop", content: [{ type: "text", text: "Unrelated answer" }] }, { id: "later-user", type: "user" }, { id: "answer", type: "assistant", time: { completed: 1 }, finish: "stop", content: [{ type: "text", text: "Saved answer result" }] }, { id: "msg_fixed", type: "user" }, ]; const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ message: { list: async () => ({ data: messages }) } }), }) as OpencodeRuntimeAdapter; expect(await adapter.readInteractionResult("s", "msg_fixed")).toBe("Saved answer result"); messages = [{ id: "partial", type: "assistant", time: {}, content: [{ type: "text", text: "Partial output" }] }, { id: "msg_fixed", type: "user" }]; await expect(adapter.readInteractionResult("s", "msg_fixed")).rejects.toThrow("do not resend"); await expect(adapter.readInteractionResult("s", "outside-window")).rejects.toThrow("outside the recovery window"); }); test("strict waiting boundary requires verified idle and rejects an exhausted deadline", async () => { const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { ensureClient: async () => ({ session: { active: async () => ({}) } }), }) as OpencodeRuntimeAdapter; await expect(adapter.waitForSessionIdle("s", 100, true)).resolves.toBeUndefined(); await expect(adapter.waitForSessionIdle("s", 0, true)).rejects.toThrow("waiting boundary"); });