fix: preserve interaction replies across chat recovery
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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<void>((resolve) => { admitted = resolve; });
|
||||
const barrier = new Promise<void>((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");
|
||||
});
|
||||
Reference in New Issue
Block a user