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" & <'), '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" & <'), 'a\n"c" & <');
-
-const partial = drainEvents('noisehello
wo');
-assert.deepEqual(partial.events, []);
-assert.equal(partial.rest, 'hello
wo');
-
-const complete = drainEvents(`${partial.rest}rldskip`);
-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");