Files
6krrt/deploy/opencode-plugin/router-link.test.mjs
adlee-was-taken 5fa869c3df fix: remove non-function export that broke opencode plugin loader
opencode 1.18 treats every export of a plugin module as a plugin
function.  router-link.js exported a Map (), so the
loader rejected the entire module: chat.headers and tool.execute.after
never ran.  The 29 node tests passed because they imported the module
directly, bypassing the loader.

- Move parentCache from a named export to a property on RouterLink
- Add loader-contract test: every module export must be a function
- Update test imports to use RouterLink.parentCache

Evidence: opencode.log at every start since 2026-09-26T08:02Z shows
"Plugin export is not a function" for router-link.js.
2026-09-26 11:26:40 -04:00

712 lines
21 KiB
JavaScript

/**
* Tests for router-link.js
*
* Uses node:test (Node >= 18) with mock.global to stub fetch and the SDK
* client. Runs against a default-hook instance of RouterLink.
*
* Tests are isolated by using distinct session ids for each parent-cache
* scenario, and resetting the global fetch mock between tool.execute.after
* tests.
*/
import { describe, it, mock, before, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { RouterLink } from "./router-link.js";
const parentCache = RouterLink.parentCache;
const SESSION_ID = "ses_test123";
const AGENT = "build";
const DIRECTORY = "/home/test/project";
// Stub SDK client. `session.get` behaviour is swapped per test.
function stubClient(sessionGetImpl) {
return {
session: {
get: sessionGetImpl,
},
};
}
// Instant successful client returning ses_parent456 for any session.
function defaultClient() {
return stubClient(async () => ({
data: { parentID: "ses_parent456" },
}));
}
// ------ helpers ------
function routerHeadersInput(overrides = {}) {
return {
sessionID: SESSION_ID,
agent: AGENT,
model: { providerID: "llm-router" },
provider: { info: { id: "llm-router" } },
...overrides,
};
}
function testToolInput(overrides = {}) {
return {
tool: "bash",
sessionID: SESSION_ID,
callID: "call_001",
args: { command: "pytest tests/" },
...overrides,
};
}
function passingOutput(overrides = {}) {
return {
exitCode: 0,
stdout: "3 passed, 0 failed",
stderr: "",
...overrides,
};
}
// ------ chat.headers tests ------
describe("chat.headers", () => {
it("sets X-Router-Conversation, X-Router-Agent, X-Router-Parent for router provider", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
// Use a unique session so cache from a previous test cannot interfere
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_router_001" }),
output,
);
assert.equal(output.headers["X-Router-Conversation"], "ses_router_001");
assert.equal(output.headers["X-Router-Agent"], AGENT);
assert.equal(output.headers["X-Router-Parent"], "ses_parent456");
});
it("sets no headers for non-router provider (model + provider both override)", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({
sessionID: "ses_nonrouter_001",
model: { providerID: "openai" },
provider: { info: { id: "openai" } },
}),
output,
);
assert.deepEqual(output.headers, {});
});
it("sets no headers when provider.info.id differs even if model.providerID is absent", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_nonrouter_002",
agent: AGENT,
model: {},
provider: { info: { id: "anthropic" } },
},
output,
);
assert.deepEqual(output.headers, {});
});
it("sets no headers when neither model.providerID nor provider.info.id nor URL matches", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_nonrouter_003",
agent: AGENT,
model: {},
provider: { info: { options: { url: "http://other:9000" } } },
},
output,
);
assert.deepEqual(output.headers, {});
});
it("falls back to provider url matching when model.providerID is absent", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_urlmatch_001",
agent: AGENT,
model: {},
provider: {
info: {
options: { url: "http://127.0.0.1:8080" },
},
},
},
output,
);
assert.equal(output.headers["X-Router-Conversation"], "ses_urlmatch_001");
});
it("falls back to provider baseURL origin matching when model.providerID is absent", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_baseurl_001",
agent: AGENT,
model: {},
provider: {
info: {
options: { baseURL: "http://127.0.0.1:8080/v1" },
},
},
},
output,
);
assert.equal(output.headers["X-Router-Conversation"], "ses_baseurl_001");
});
it("sets no headers when the baseURL origin differs from the router origin", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_baseurl_002",
agent: AGENT,
model: {},
provider: {
info: {
options: { baseURL: "http://127.0.0.1:9999/v1" },
},
},
},
output,
);
assert.deepEqual(output.headers, {});
});
it("sets no headers on a malformed baseURL and does not throw", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_baseurl_003",
agent: AGENT,
model: {},
provider: {
info: {
options: { baseURL: "not-a-valid-url" },
},
},
},
output,
);
assert.deepEqual(output.headers, {});
});
it("omits X-Router-Parent when parent lookup takes >250ms", async () => {
const slowClient = stubClient(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ data: { parentID: "ses_slow" } }), 300),
),
);
const hooks = await RouterLink({
client: slowClient,
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_slowparent_001" }),
output,
);
assert.equal(output.headers["X-Router-Conversation"], "ses_slowparent_001");
assert.equal(output.headers["X-Router-Agent"], AGENT);
// Parent timed out, so no parent header
assert.equal(output.headers["X-Router-Parent"], undefined);
});
it("omits X-Router-Parent when parent lookup throws and does not throw", async () => {
const brokenClient = stubClient(async () => {
throw new Error("session store unreachable");
});
const hooks = await RouterLink({
client: brokenClient,
directory: DIRECTORY,
});
const output = { headers: {} };
// Must not throw
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_throws_001" }),
output,
);
assert.equal(output.headers["X-Router-Conversation"], "ses_throws_001");
assert.equal(output.headers["X-Router-Agent"], AGENT);
assert.equal(output.headers["X-Router-Parent"], undefined);
});
it("caches successful parent lookups so repeated calls do not hit the SDK", async () => {
let callCount = 0;
const countingClient = stubClient(async () => {
callCount++;
return { data: { parentID: "ses_parentCached" } };
});
const hooks = await RouterLink({
client: countingClient,
directory: DIRECTORY,
});
const output1 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_counting_001" }),
output1,
);
assert.equal(callCount, 1);
// Second call with same session id should use cache
const output2 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_counting_001" }),
output2,
);
assert.equal(callCount, 1); // still 1 — cache hit
assert.equal(output2.headers["X-Router-Parent"], "ses_parentCached");
});
it("does not cache failed lookups (so a retry can try again)", async () => {
let fail = true;
const flakyClient = stubClient(async () => {
if (fail) throw new Error("transient");
return { data: { parentID: "ses_parentFlaky" } };
});
const hooks = await RouterLink({
client: flakyClient,
directory: DIRECTORY,
});
// First call: fails, no cache
const output1 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_flaky_001" }),
output1,
);
assert.equal(output1.headers["X-Router-Parent"], undefined);
// Second call: SDK succeeds — should NOT have cached the failure
fail = false;
const output2 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_flaky_001" }),
output2,
);
assert.equal(output2.headers["X-Router-Parent"], "ses_parentFlaky");
});
});
// ------ chat.headers agent slugging ------
describe("chat.headers agent slugging", () => {
it("slugs 'Atlas - Plan Executor' to 'atlas-plan-executor'", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({
sessionID: "ses_slug_001",
agent: "Atlas - Plan Executor",
}),
output,
);
assert.equal(output.headers["X-Router-Agent"], "atlas-plan-executor");
});
it("slugs 'Sisyphus - ultraworker' to 'sisyphus-ultraworker'", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({
sessionID: "ses_slug_002",
agent: "Sisyphus - ultraworker",
}),
output,
);
assert.equal(output.headers["X-Router-Agent"], "sisyphus-ultraworker");
});
it("leaves a name already in the router charset unchanged", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({
sessionID: "ses_slug_003",
agent: "oh-my-claudecode:executor",
}),
output,
);
assert.equal(
output.headers["X-Router-Agent"],
"oh-my-claudecode:executor",
);
});
it("sends no X-Router-Agent when the name is only spaces", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_slug_004", agent: " " }),
output,
);
// The hook itself still ran and stamped the conversation.
assert.equal(output.headers["X-Router-Conversation"], "ses_slug_004");
assert.equal(output.headers["X-Router-Agent"], undefined);
});
it("cuts a 100-character name to the router's 64-char limit", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({
sessionID: "ses_slug_005",
agent: "a".repeat(100),
}),
output,
);
assert.equal(output.headers["X-Router-Agent"], "a".repeat(64));
});
it("sends no X-Router-Agent when input.agent is absent", async () => {
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const output = { headers: {} };
await hooks["chat.headers"](
{
sessionID: "ses_slug_006",
model: { providerID: "llm-router" },
provider: { info: { id: "llm-router" } },
},
output,
);
assert.equal(output.headers["X-Router-Agent"], undefined);
});
});
// ------ tool.execute.after tests ------
describe("tool.execute.after", () => {
beforeEach(() => {
mock.restoreAll();
});
it("POSTs pass outcome with conversation_id, ok=true, and source", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () =>
new Response(null, { status: 200 }),
);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
await hooks["tool.execute.after"](
testToolInput({ sessionID: "ses_outcome_001" }),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 1);
const [url, opts] = fetchMock.mock.calls[0].arguments;
assert.match(url, /\/outcome$/);
const body = JSON.parse(opts.body);
assert.equal(body.ok, true);
assert.equal(body.source, DIRECTORY);
assert.equal(body.conversation_id, "ses_outcome_001");
assert.match(body.detail, /passed/);
});
it("POSTs fail outcome with ok=false when tool exits non-zero", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () =>
new Response(null, { status: 200 }),
);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
const input = testToolInput({ sessionID: "ses_outcome_002" });
const output = passingOutput({ exitCode: 1, stdout: "1 failed" });
await hooks["tool.execute.after"](input, output);
assert.equal(fetchMock.mock.callCount(), 1);
const body = JSON.parse(fetchMock.mock.calls[0].arguments[1].body);
assert.equal(body.ok, false);
assert.match(body.detail, /failed/);
});
it("POSTs nothing for a non-test command", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
await hooks["tool.execute.after"](
testToolInput({
sessionID: "ses_other_001",
args: { command: "ls -la" },
}),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 0);
});
it("does not throw when router is unreachable (network error)", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () => {
throw new TypeError("fetch failed");
});
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
// Must not throw
await hooks["tool.execute.after"](
testToolInput({ sessionID: "ses_unreach_001" }),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 1);
});
it("logs and swallows a 409 response", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () =>
new Response(null, { status: 409 }),
);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
// Must not throw
await hooks["tool.execute.after"](
testToolInput({ sessionID: "ses_409_001" }),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 1);
});
it("logs non-ok 4xx (but not 404) without throwing", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () =>
new Response(null, { status: 502 }),
);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
// Must not throw
await hooks["tool.execute.after"](
testToolInput({ sessionID: "ses_502_001" }),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 1);
});
it("accepts 404 silently (old router without /outcome)", async () => {
const fetchMock = mock.fn();
mock.method(globalThis, "fetch", fetchMock);
fetchMock.mock.mockImplementation(async () =>
new Response(null, { status: 404 }),
);
const hooks = await RouterLink({
client: defaultClient(),
directory: DIRECTORY,
});
await hooks["tool.execute.after"](
testToolInput({ sessionID: "ses_404_001" }),
passingOutput(),
);
assert.equal(fetchMock.mock.callCount(), 1);
});
});
// ------ parent cache tests ------
describe("parent cache", () => {
it("caches a root session (no parentID) so two requests call the SDK once", async () => {
let callCount = 0;
const countingClient = stubClient(async () => {
callCount++;
return { data: {} }; // no parentID -- root session
});
const hooks = await RouterLink({
client: countingClient,
directory: DIRECTORY,
});
// First call: SDK hit, null cached, no X-Router-Parent
const output1 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_root_001" }),
output1,
);
assert.equal(callCount, 1);
assert.equal(output1.headers["X-Router-Parent"], undefined);
// Second call with same session: cache hit, SDK NOT called
const output2 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_root_001" }),
output2,
);
assert.equal(callCount, 1); // still 1
assert.equal(output2.headers["X-Router-Parent"], undefined);
});
it("retries a timed-out lookup on the next request", async () => {
let callCount = 0;
const slowClient = stubClient(() => {
callCount++; // count synchronously on each call
return new Promise((resolve) =>
setTimeout(() => resolve({ data: { parentID: "ses_parentSlow" } }), 300),
);
});
const hooks = await RouterLink({
client: slowClient,
directory: DIRECTORY,
});
// First call: timeout wins (300 ms > 250 ms), SDK call counted
const output1 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_timoutretry_001" }),
output1,
);
assert.equal(callCount, 1);
assert.equal(output1.headers["X-Router-Parent"], undefined);
// Second call: not cached, SDK called again
const output2 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_timoutretry_001" }),
output2,
);
assert.equal(callCount, 2); // SDK retried
assert.equal(output2.headers["X-Router-Parent"], undefined);
});
it("retries a throwing lookup on the next request", async () => {
let callCount = 0;
const brokenClient = stubClient(() => {
callCount++; // count synchronously before rejecting
return Promise.reject(new Error("session store down"));
});
const hooks = await RouterLink({
client: brokenClient,
directory: DIRECTORY,
});
// First call: throws, no cache
const output1 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_throwretry_001" }),
output1,
);
assert.equal(callCount, 1);
assert.equal(output1.headers["X-Router-Parent"], undefined);
// Second call: not cached, SDK called again
const output2 = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: "ses_throwretry_001" }),
output2,
);
assert.equal(callCount, 2); // SDK retried
assert.equal(output2.headers["X-Router-Parent"], undefined);
});
it("evicts the oldest entry when cache exceeds 1000", async () => {
parentCache.clear();
const noParentClient = stubClient(async () => ({ data: {} }));
const hooks = await RouterLink({
client: noParentClient,
directory: DIRECTORY,
});
// Insert 1001 entries
for (let i = 0; i < 1001; i++) {
const output = { headers: {} };
await hooks["chat.headers"](
routerHeadersInput({ sessionID: `ses_evict_${String(i).padStart(4, "0")}` }),
output,
);
}
assert.equal(parentCache.size, 1000);
// Oldest entry (ses_evict_0000) evicted
assert.equal(parentCache.has("ses_evict_0000"), false);
// Newest entry still present
assert.equal(parentCache.has("ses_evict_1000"), true);
// An entry in the middle still present
assert.equal(parentCache.has("ses_evict_0500"), true);
});
});
describe("loader contract", () => {
it("every named export from the module is a function (opencode rejects non-function exports)", async () => {
const mod = await import("./router-link.js");
for (const [name, value] of Object.entries(mod)) {
assert.equal(typeof value, "function", `export "${name}" must be a function`);
}
});
});