diff --git a/tests/omp-extension.mjs b/tests/omp-extension.mjs index 2ccf034..4efe0d1 100644 --- a/tests/omp-extension.mjs +++ b/tests/omp-extension.mjs @@ -1,25 +1,454 @@ import assert from "node:assert/strict"; -import { decodeBody, drainEvents, extractReply } from "../adapter/strings/omp-spt.mjs"; +import { EventEmitter } from "node:events"; +import { + createOmpSpt, + decodeBody, + drainEvents, + extractReply, +} from "../adapter/strings/omp-spt.mjs"; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +class FakeStream extends EventEmitter { + setEncoding(encoding) { + this.encoding = encoding; + } +} + +class FakeChild extends EventEmitter { + constructor() { + super(); + this.stdout = new FakeStream(); + this.stderr = new FakeStream(); + this.kills = 0; + } + + kill() { + this.kills += 1; + } +} + +class FakeClock { + constructor() { + this.nextId = 1; + this.timers = new Map(); + } + + setTimeout(fn, delay) { + const handle = { id: this.nextId++, unref() {} }; + this.timers.set(handle, { fn, delay }); + return handle; + } + + clearTimeout(handle) { + this.timers.delete(handle); + } + + delays() { + return [...this.timers.values()].map(({ delay }) => delay); + } + + async runNext(expectedDelay) { + const entry = [...this.timers.entries()].sort((left, right) => left[1].delay - right[1].delay)[0]; + assert.ok(entry, `expected a ${expectedDelay}ms timer`); + const [handle, timer] = entry; + assert.equal(timer.delay, expectedDelay); + this.timers.delete(handle); + timer.fn(); + await flush(); + } +} + +function createHarness(options = {}) { + const handlers = new Map(); + const calls = []; + const children = []; + const submitted = []; + const statuses = []; + const notifications = []; + const errors = []; + const debug = []; + const clock = new FakeClock(); + let shutdowns = 0; + + const ui = { + notify(message, type) { + notifications.push({ message, type }); + }, + setStatus(key, text) { + statuses.push({ key, text }); + }, + }; + const ctx = { + ui, + sessionManager: { getSessionId: () => options.sessionId ?? "session-1" }, + shutdown() { + shutdowns += 1; + }, + }; + const pi = { + logger: { + error(message, details) { + errors.push({ message, details }); + }, + debug(message, details) { + debug.push({ message, details }); + }, + }, + on(name, handler) { + const registered = handlers.get(name) ?? []; + registered.push(handler); + handlers.set(name, registered); + }, + sendUserMessage(content) { + submitted.push(content); + options.onSubmit?.(content); + }, + }; + const runSptCommand = async (args, input) => { + const call = { args: [...args], input }; + calls.push(call); + const overridden = await options.onRun?.(call); + if (overridden !== undefined) return overridden; + if (args[0] === "api" && args[3] === "bind") { + return options.bindOutput ?? "BOUND endpoint token=token-123"; + } + return ""; + }; + const spawnProcess = (binary, args, spawnOptions) => { + const child = new FakeChild(); + child.binary = binary; + child.args = [...args]; + child.spawnOptions = spawnOptions; + children.push(child); + return child; + }; + const extension = createOmpSpt({ + env: { + SPT_ENDPOINT_ID: options.id ?? "omp-agent", + OMP_SPT_SUBNET: options.subnet, + OMP_SPT_SPT_BIN: "spt-test", + }, + restartDelaysMs: options.restartDelaysMs ?? [5, 10], + runSptCommand, + spawnProcess, + setTimeout: clock.setTimeout.bind(clock), + clearTimeout: clock.clearTimeout.bind(clock), + }); + extension(pi); + + async function emit(name, event = {}) { + let result; + for (const handler of handlers.get(name) ?? []) { + const returned = await handler({ type: name, ...event }, ctx); + if (returned !== undefined) result = returned; + } + return result; + } + + return { + calls, + children, + clock, + ctx, + debug, + emit, + errors, + handlers, + notifications, + statuses, + submitted, + get shutdowns() { + return shutdowns; + }, + }; +} + +function commandCalls(harness, command) { + return harness.calls.filter((call) => call.args[0] === command); +} + +function stateCalls(harness) { + return harness.calls.filter((call) => call.args[0] === "api" && call.args[3] === "state"); +} + +async function testParsingAndReplies() { + assert.equal(decodeBody('a<b>
"c" & &lt;'), 'a\n"c" & <'); + + const partialEnvelope = 'hello
wo'; + const partial = drainEvents(`noise${partialEnvelope}`); + assert.deepEqual(partial.events, []); + assert.equal(partial.rest, partialEnvelope); + + const envelope = `${partial.rest}rld
`; + const complete = drainEvents(`${envelope}skip`); + assert.deepEqual(complete.events, [ + { from: "doyle", body: "hello\nworld", envelope }, + ]); + assert.equal(complete.rest, ""); + + assert.equal( + extractReply([ + { role: "assistant", content: [{ type: "text", text: "first" }] }, + { role: "toolResult", content: [] }, + { + role: "assistant", + content: [ + { type: "text", text: "final " }, + { type: "text", text: "answer" }, + ], + }, + ]), + "final answer", + ); + assert.equal( + extractReply( + [ + { role: "assistant", content: "stale answer" }, + { role: "user", content: '' }, + ], + '', + ), + "", + ); +} // [unit->REQ-OMP-NATIVE-TUI] -assert.equal(decodeBody('a<b>
"c" & &lt;'), 'a\n"c" & <'); - -const partial = drainEvents('noisehello
wo'); -assert.deepEqual(partial.events, []); -assert.equal(partial.rest, 'hello
wo'); - -const complete = drainEvents(`${partial.rest}rld
skip`); -assert.deepEqual(complete.events, [{ from: "doyle", body: "hello\nworld" }]); -assert.equal(complete.rest, ""); - -assert.equal( - extractReply([ - { role: "assistant", content: [{ type: "text", text: "first" }] }, - { role: "toolResult", content: [] }, - { role: "assistant", content: [{ type: "text", text: "final " }, { type: "text", text: "answer" }] }, - ]), - "final answer", -); -assert.equal(extractReply([{ role: "user", content: "hello" }]), ""); +async function testLifecycleCustodyAndContext() { + const harness = createHarness({ subnet: "mesh-a" }); + assert.deepEqual([...harness.handlers.keys()], [ + "session_start", + "session_before_switch", + "context", + "agent_start", + "agent_end", + "session_shutdown", + ]); + + await harness.emit("session_start"); + assert.deepEqual(harness.calls[0], { + args: [ + "api", + "--adapter", + "omp-spt", + "bind", + "omp-agent", + "--set-session-id", + "session-1", + "--subnet", + "mesh-a", + ], + input: undefined, + }); + assert.deepEqual(harness.calls[1].args, [ + "api", + "--adapter", + "omp-spt", + "state", + "idle", + "omp-agent", + "--token", + "token-123", + ]); + assert.equal(harness.children.length, 1); + assert.equal(harness.children[0].binary, "spt-test"); + assert.deepEqual(harness.children[0].args, ["ready", "omp-agent", "--subnet", "mesh-a"]); + + const blockedResume = await harness.emit("session_before_switch", { reason: "resume" }); + const blockedNew = await harness.emit("session_before_switch", { reason: "new" }); + assert.deepEqual(blockedResume, { cancel: true }); + assert.deepEqual(blockedNew, { cancel: true }); + assert.ok(harness.notifications.some(({ message }) => message.includes("resume session switch"))); + + const aliceEnvelope = + 'hello<world
line
'; + const bobEnvelope = 'second'; + harness.children[0].stdout.emit("data", `${aliceEnvelope}${bobEnvelope}`); + await flush(); + assert.deepEqual(harness.submitted, ['']); + assert.deepEqual( + stateCalls(harness).map((call) => call.args[4]), + ["idle", "busy"], + ); + + const originalMessages = [{ role: "user", content: '' }]; + const context = await harness.emit("context", { messages: originalMessages }); + assert.equal(originalMessages[0].content, ''); + assert.equal(context.messages[0].content, `\n\n${aliceEnvelope}`); + + const arrayContext = await harness.emit("context", { + messages: [{ role: "user", content: [{ type: "text", text: '' }] }], + }); + assert.deepEqual(arrayContext.messages[0].content, [ + { type: "text", text: '' }, + { type: "text", text: `\n\n${aliceEnvelope}` }, + ]); + + await harness.emit("agent_start"); + assert.deepEqual( + stateCalls(harness).map((call) => call.args[4]), + ["idle", "busy"], + "agent_start must not duplicate the already-honest busy transition", + ); + await harness.emit("agent_end", { + messages: [ + { role: "user", content: '' }, + { role: "assistant", content: [{ type: "text", text: "alice reply" }] }, + ], + }); + assert.deepEqual(harness.submitted, ['']); + assert.deepEqual(harness.clock.delays(), [0]); + + await harness.clock.runNext(0); + assert.deepEqual(harness.submitted, ['', '']); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { role: "assistant", content: "previous reply" }, + { role: "user", content: '' }, + ], + }); + + const outcomes = commandCalls(harness, "send"); + assert.equal(outcomes.length, 2); + assert.deepEqual( + outcomes.map((call) => call.args), + [ + ["send", "alice", "--from", "omp-agent"], + ["send", "bob", "--from", "omp-agent"], + ], + ); + assert.equal(outcomes[0].input, "alice reply"); + assert.match(outcomes[1].input, /turn ended without an assistant response/); + assert.deepEqual( + stateCalls(harness).map((call) => call.args[4]), + ["idle", "busy", "idle", "busy", "idle"], + ); + for (const call of [...stateCalls(harness), ...harness.calls.filter((candidate) => candidate.args[3] === "session-end")]) { + assert.deepEqual(call.args.slice(-2), ["--token", "token-123"]); + } + + await harness.emit("session_shutdown"); + const ended = harness.calls.filter((call) => call.args[3] === "session-end"); + assert.equal(ended.length, 1); + assert.deepEqual(ended[0].args.slice(-2), ["--token", "token-123"]); + assert.equal(harness.children[0].kills, 1); + assert.deepEqual(harness.clock.delays(), []); +} + +async function testSubmissionFailureAdvancesQueue() { + const harness = createHarness({ + onSubmit(content) { + if (content === '') throw new Error("OMP prompt flow rejected input"); + }, + }); + await harness.emit("session_start"); + harness.children[0].stdout.emit( + "data", + 'onetwo', + ); + await flush(); + + const firstOutcome = commandCalls(harness, "send"); + assert.equal(firstOutcome.length, 1); + assert.deepEqual(firstOutcome[0].args, ["send", "broken", "--from", "omp-agent"]); + assert.match(firstOutcome[0].input, /could not submit your message to OMP/); + assert.deepEqual(harness.clock.delays(), [0]); + + await harness.clock.runNext(0); + assert.deepEqual(harness.submitted, ['', '']); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { role: "user", content: '' }, + { role: "assistant", content: "next reply" }, + ], + }); + const outcomes = commandCalls(harness, "send"); + assert.equal(outcomes.length, 2); + assert.deepEqual( + outcomes.map((call) => call.args[1]), + ["broken", "next"], + ); + assert.equal(outcomes[1].input, "next reply"); + await harness.emit("session_shutdown"); + assert.deepEqual(harness.clock.delays(), []); +} + +async function testListenerRestartExhaustion() { + const harness = createHarness({ restartDelaysMs: [5, 10] }); + await harness.emit("session_start"); + const first = harness.children[0]; + first.emit("close", 7); + assert.deepEqual(harness.clock.delays(), [5]); + + await harness.clock.runNext(5); + const second = harness.children[1]; + second.emit("error", new Error("listener crashed")); + second.emit("close", 8); + assert.deepEqual(harness.clock.delays(), [10], "error plus close schedules one restart"); + + await harness.clock.runNext(10); + const third = harness.children[2]; + third.emit("close", 9); + await flush(); + + assert.equal(harness.children.length, 3); + assert.equal(harness.shutdowns, 1); + assert.ok( + harness.errors.some(({ message }) => message.includes("listener restart budget exhausted")), + ); + assert.ok( + harness.notifications.some(({ message, type }) => + type === "error" && message.includes("listener restart budget exhausted"), + ), + ); + assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); + assert.deepEqual(harness.clock.delays(), []); + + await harness.emit("session_shutdown"); + assert.equal( + harness.calls.filter((call) => call.args[3] === "session-end").length, + 1, + "fatal shutdown and lifecycle shutdown share one teardown", + ); +} + +async function testShutdownReapsAndFailsQueuedCustody() { + const harness = createHarness(); + await harness.emit("session_start"); + const listener = harness.children[0]; + listener.stdout.emit( + "data", + 'onetwo', + ); + await flush(); + await harness.emit("agent_start"); + await harness.emit("agent_end", { + messages: [ + { role: "user", content: '' }, + { role: "assistant", content: "done" }, + ], + }); + assert.deepEqual(harness.clock.delays(), [0]); + + await harness.emit("session_shutdown"); + assert.equal(listener.kills, 1); + assert.deepEqual(harness.clock.delays(), []); + assert.deepEqual( + commandCalls(harness, "send").map((call) => call.args[1]), + ["first", "queued"], + ); + assert.match(commandCalls(harness, "send")[1].input, /OMP session shut down/); + assert.equal(harness.calls.filter((call) => call.args[3] === "session-end").length, 1); + assert.deepEqual(harness.statuses.at(-1), { key: "omp-spt", text: undefined }); + assert.equal(harness.shutdowns, 0, "normal lifecycle shutdown must not recursively shut down OMP"); +} + +await testParsingAndReplies(); +await testLifecycleCustodyAndContext(); +await testSubmissionFailureAdvancesQueue(); +await testListenerRestartExhaustion(); +await testShutdownReapsAndFailsQueuedCustody(); console.log("OMP-EXTENSION OK");