Files
backnotprop__plannotator/apps/opencode-plugin/server.test.ts
Michael Ramos 7ee366d8a1 fix(opencode): show the session URL on OpenCode 2's native command path (#1435)
* fix(opencode): show the session URL on OpenCode 2's native command path

On OpenCode 2 a remote session's URL was invisible. runNativeCommand builds
its bridge client with createV2BridgeClient, which deliberately has no tui
domain, so toastPlannotatorUrl optional-chained to a no-op; both URL delivery
paths (the CLI stderr forwarder and the ready-file poller) route through it.
The V2 client's app.log is console.error, and OpenCode discards a server
plugin's stderr under both default launch modes (packages/cli/src/services/
standalone.ts uses stderr: "ignore" unless OPENCODE_PRINT_LOGS=1). Remote mode
also suppresses the browser, so /plannotator-review showed the user nothing at
all and presented as a hang.

Deliver the URL as a visible transcript notice instead. createSessionUrlNotifier
duck-types ctx.session.synthetic and exposes it to cli-bridge as notifyUrl, a
seam toastPlannotatorUrl prefers over the toast when present; OpenCode 1 clients
carry no notifyUrl and keep their real toast unchanged. The notice is posted
with resume: false, which upstream skips the wake for, so nothing starts a model
turn, and it carries the URL in both text and description because the TUI drops
a synthetic row whose description is empty and renders the description rather
than the text. Everything is guarded: a host without session.synthetic, or a
call with no session, gets no notifier and falls back to today's log-only
behavior, and a rejecting synthetic is caught and leaves the URL retryable by
the other delivery path.

The README's remedy line claimed remote sessions should read the URL from the
OpenCode log, which was never true; it now describes the transcript notice and
names OPENCODE_PRINT_LOGS=1 for older hosts.

Also fixes two bugs in the OpenCode 2 native-command smoke:

- scripts/opencode2-native-commands-smoke.sh looked for a node_modules/.bin/
  opencode binary. @opencode-ai/cli publishes opencode2 on every dist-tag, so
  the script failed before it started a server. It now tries both names and
  reports which it looked for.
- The command-ownership check read /api/command once, immediately after
  activation, racing the reclaim schedule whose last tick lands about 15.5s
  later. Under PLANNOTATOR_SMOKE_EXPECT_NATIVE=1 that reported a shadowing bug
  the reclaim had simply not reached yet. It now polls to a 30s deadline
  (PLANNOTATOR_SMOKE_COMMAND_TIMEOUT_MS), still only after /api/plugin reports
  the plugin loaded.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): deliver the session URL on OpenCode 2's plan review path too

The first commit fixed only the native command path. The plan path builds its
own client (createV2Client, typed as { app: { agents, log } } with no notifier),
so a remote OpenCode 2 user who reached a review through submit_plan still never
saw the URL: no browser is opened for them and the plugin's console output is
discarded by the host.

The plan path now builds the same bridge client the command path uses, with
toolContext.sessionID, so it carries notifyUrl whenever the host exposes
session.synthetic. That covers both runtimes: the CLI runtime already prefers
notifyUrl inside toastPlannotatorUrl, and the embedded runtime's previously
empty logReady hook is now createPlanReadyNotifier.

That hook still does not log. app.log is console.error, the same stderr
handleServerReady already printed the URL to, so logging there would duplicate
the line in remote mode and add a stray one locally, which is why the hook was
empty. The transcript notice is a different surface, and it is the only one a
remote reviewer can see. Without session.synthetic the hook stays silent exactly
as before.

createV2Client is gone: it duplicated the bridge client's URL-deduped app.log
verbatim, and nothing else used it.

Three tests on the plan path (delivers the notice; stays silent and does not
re-log without synthetic; catches a rejecting notice) plus one that pins the two
wiring seams at source level, since the notifier tests all pass while the plan
path is wired to nothing, which is the shape the bug had.

Also from review: console.error is stubbed across the V2 URL delivery block, so
those tests no longer print URL lines into the suite output. The README bullet
now says the notice covers every way a session opens rather than slash commands
alone.

AI-assisted (Claude) under maintainer direction.
2026-08-31 13:12:23 -07:00

454 lines
18 KiB
TypeScript

import { afterEach, describe, expect, mock, test } from "bun:test";
import serverPlugin, {
createPlanReadyNotifier,
pushComposedSystemReminder,
replacePlanningSystemParts,
} from "./server";
import { createV2BridgeClient, formatSessionUrlNotice } from "./v2-client";
import { readFileSync } from "node:fs";
import path from "node:path";
const originalAllowSubagents = process.env.PLANNOTATOR_ALLOW_SUBAGENTS;
afterEach(() => {
if (originalAllowSubagents === undefined) delete process.env.PLANNOTATOR_ALLOW_SUBAGENTS;
else process.env.PLANNOTATOR_ALLOW_SUBAGENTS = originalAllowSubagents;
});
type SessionContextHook = (event: {
agent: string;
system: Array<{ type: "text"; text: string }>;
messages: unknown[];
tools: Record<string, { description: string; input: Record<string, unknown> }>;
}) => Promise<void> | void;
function createContext(
options: Record<string, unknown> = {},
agents: Array<{ id: string; description?: string; mode: string; hidden: boolean }> = [],
hostOverrides: {
// Pre-#44765 hosts DO expose command.transform, but hand the callback a
// draft with no `add`. The adapter must then register nothing, throw
// nothing, and behave exactly as it did before.
command?: { transform: (apply: (draft: any) => void) => Promise<unknown> };
agentListShape?: "envelope" | "array";
} = {},
) {
let toolDefinition: Record<string, any> | undefined;
let sessionContextHook: SessionContextHook | undefined;
const sessionGet = mock(async () => ({ location: { directory: "/project" } }));
return {
context: {
options,
...(hostOverrides.command ? { command: hostOverrides.command } : {}),
agent: {
list: async () => (hostOverrides.agentListShape === "array"
? agents
: { location: { directory: "/project" }, data: agents }),
transform: async () => ({ dispose: async () => {} }),
},
session: {
get: sessionGet,
hook: async (name: string, callback: SessionContextHook) => {
if (name === "context") sessionContextHook = callback;
return { dispose: async () => {} };
},
},
tool: {
transform: async (callback: (draft: { add: (tool: Record<string, any>) => void }) => void) => {
callback({
add(tool) {
toolDefinition = tool;
},
});
return { dispose: async () => {} };
},
},
},
getToolDefinition: () => toolDefinition,
getSessionContextHook: () => sessionContextHook,
sessionGet,
};
}
describe("OpenCode V2 server plugin", () => {
test("exports a stable V2 plugin object", () => {
expect(serverPlugin.id).toBe("plannotator");
expect(serverPlugin.setup).toBeInstanceOf(Function);
});
test("registers submit_plan with the V2 JSON Schema tool contract", async () => {
const testContext = createContext();
await serverPlugin.setup(testContext.context as never);
const tool = testContext.getToolDefinition();
expect(tool?.name).toBe("submit_plan");
expect(tool?.input).toEqual({
type: "object",
properties: {
edits: {
type: "array",
items: {
type: "object",
properties: {
start: { type: "number", description: "1-indexed start line (inclusive)" },
end: {
type: "number",
description: "1-indexed end line (inclusive). Omit to replace from start through end of file.",
},
content: { type: "string", description: "Replacement content. Empty string deletes the line range." },
},
required: ["start", "content"],
additionalProperties: false,
},
description: "Array of line-range edits to apply to the plan.",
},
},
required: ["edits"],
additionalProperties: false,
});
expect(tool?.options).toEqual({ codemode: false });
expect(tool?.execute).toBeInstanceOf(Function);
});
test("resolves cwd from the V2 session and returns V2 tool content", async () => {
const testContext = createContext();
await serverPlugin.setup(testContext.context as never);
const result = await testContext.getToolDefinition()?.execute(
{ edits: [] },
{
sessionID: "session-1",
agent: "plan",
messageID: "message-1",
callID: "call-1",
progress: async () => {},
},
);
expect(testContext.sessionGet).toHaveBeenCalledWith({ sessionID: "session-1" });
expect(result).toEqual({
content: "Error: No edits provided. Pass at least one edit with start and content.",
});
});
test("uses the context hook for planning prompts and tool visibility", async () => {
const testContext = createContext();
await serverPlugin.setup(testContext.context as never);
const hook = testContext.getSessionContextHook();
expect(hook).toBeInstanceOf(Function);
const planningEvent = {
agent: "plan",
system: [
{ type: "text" as const, text: "Base system prompt", metadata: { source: "base" } },
{ type: "text" as const, text: "Earlier plugin prompt", cache: { type: "ephemeral" } },
],
messages: [],
tools: {
submit_plan: { description: "Submit", input: {} },
plan_exit: { description: "Exit", input: {} },
todowrite: { description: "Write todos", input: {} },
},
};
await hook?.(planningEvent);
// #1114: the planning path emits ONE composed system part (multi-part
// system arrays corrupt Qwen3.x Jinja chat templates). Existing text
// survives, in order, ahead of the planning prompt.
expect(planningEvent.system.length).toBe(1);
const composedText = planningEvent.system[0]!.text;
expect(composedText).toContain("Base system prompt");
expect(composedText).toContain("Earlier plugin prompt");
expect(composedText).toContain("## Plannotator");
expect(composedText.indexOf("Base system prompt"))
.toBeLessThan(composedText.indexOf("Earlier plugin prompt"));
expect(composedText.indexOf("Earlier plugin prompt"))
.toBeLessThan(composedText.indexOf("## Plannotator"));
expect(planningEvent.tools.plan_exit.description).toContain("Use submit_plan instead");
expect(planningEvent.tools.todowrite.description).toContain("use submit_plan instead");
const buildEvent = {
agent: "build",
system: [{ type: "text" as const, text: "Base system prompt" }],
messages: [],
tools: {
submit_plan: { description: "Submit", input: {} },
},
};
await hook?.(buildEvent);
expect(buildEvent.tools.submit_plan).toBeUndefined();
expect(buildEvent.system).toEqual([{ type: "text", text: "Base system prompt" }]);
const strippedEvent = {
agent: "plan",
system: [{ type: "text" as const, text: "Call plan_exit when ready." }],
messages: [],
tools: {
submit_plan: { description: "Submit", input: {} },
},
};
await hook?.(strippedEvent);
const strippedSystemText = strippedEvent.system.map((part) => part.text);
expect(strippedSystemText.some((text) => text.startsWith("## Plannotator"))).toBe(true);
expect(strippedSystemText.join("\n")).not.toContain("undefined");
});
test("keeps all-agents mode scoped to primary agents by default", async () => {
delete process.env.PLANNOTATOR_ALLOW_SUBAGENTS;
const testContext = createContext(
{ workflow: "all-agents" },
[{ id: "researcher", mode: "subagent", hidden: false }],
);
await serverPlugin.setup(testContext.context as never);
const event = {
agent: "researcher",
system: [{ type: "text" as const, text: "Base system prompt" }],
messages: [],
tools: {
submit_plan: { description: "Submit", input: {} },
},
};
await testContext.getSessionContextHook()?.(event);
expect(event.tools.submit_plan).toBeUndefined();
});
test("registers the slash commands only on a host that exposes the command API", async () => {
const registered: string[] = [];
const withCommands = createContext({}, [], {
command: {
transform: async (apply) => {
apply({ add: (definition: { name: string }) => registered.push(definition.name) });
return { dispose: async () => {} };
},
},
});
await serverPlugin.setup(withCommands.context as never);
expect(registered).toEqual([
"plannotator-review",
"plannotator-annotate",
"plannotator-last",
]);
// No command domain: nothing registered, and the pre-existing submit_plan
// contract is untouched.
const withoutCommands = createContext();
await serverPlugin.setup(withoutCommands.context as never);
expect(withoutCommands.getToolDefinition()?.name).toBe("submit_plan");
});
test("a pre-#44765 command draft registers nothing and does not fail setup", async () => {
// The real `next` / `latest` shape: transform exists, the draft is
// { list, get, update, remove }. Touching `add` here would throw inside the
// host's batched reload flush and abort it before commit.
let applied = false;
const testContext = createContext({}, [], {
command: {
transform: async (apply) => {
applied = true;
apply({ list: () => [], get: () => undefined, update: () => {}, remove: () => {} });
return { dispose: async () => {} };
},
},
});
await serverPlugin.setup(testContext.context as never);
expect(applied).toBe(true);
expect(testContext.getToolDefinition()?.name).toBe("submit_plan");
});
test("a rejecting command transform never fails plugin setup", async () => {
// A slash command has a working markdown fallback; the whole Plannotator
// integration going down for it would not.
const testContext = createContext({}, [], {
command: { transform: async () => { throw new Error("command domain unavailable"); } },
});
const originalError = console.error;
console.error = () => {};
try {
await serverPlugin.setup(testContext.context as never);
} finally {
console.error = originalError;
}
expect(testContext.getToolDefinition()?.name).toBe("submit_plan");
});
test("registers slash commands even when submit_plan is disabled", async () => {
// `workflow: "manual"` returns early before the tool registration, which is
// exactly the mode that depends on the slash commands existing.
const registered: string[] = [];
const testContext = createContext({ workflow: "manual" }, [], {
command: {
transform: async (apply) => {
apply({ add: (definition: { name: string }) => registered.push(definition.name) });
return { dispose: async () => {} };
},
},
});
await serverPlugin.setup(testContext.context as never);
expect(registered).toHaveLength(3);
expect(testContext.getToolDefinition()).toBeUndefined();
});
test("reads a bare-array agent list, so subagent gating still applies", async () => {
// Newer plugin hosts answer agent.list() with an array rather than the
// `{ data }` envelope; reading `.data` blindly emptied the list and let
// subagents keep submit_plan.
delete process.env.PLANNOTATOR_ALLOW_SUBAGENTS;
const testContext = createContext(
{ workflow: "all-agents" },
[{ id: "researcher", mode: "subagent", hidden: false }],
{ agentListShape: "array" },
);
await serverPlugin.setup(testContext.context as never);
const event = {
agent: "researcher",
system: [{ type: "text" as const, text: "Base system prompt" }],
messages: [],
tools: { submit_plan: { description: "Submit", input: {} } },
};
await testContext.getSessionContextHook()?.(event);
expect(event.tools.submit_plan).toBeUndefined();
});
test("generic reminder composes into the existing part instead of pushing a second one", async () => {
process.env.PLANNOTATOR_ALLOW_SUBAGENTS = "1";
const testContext = createContext(
{ workflow: "all-agents" },
[{ id: "helper", mode: "primary", hidden: false }],
);
await serverPlugin.setup(testContext.context as never);
const event = {
agent: "helper",
system: [{ type: "text" as const, text: "Base system prompt" }],
messages: [],
tools: {
submit_plan: { description: "Submit", input: {} },
},
};
await testContext.getSessionContextHook()?.(event);
// #1114: a second system part corrupts Qwen3.x Jinja templates.
expect(event.system.length).toBe(1);
expect(event.system[0]!.text).toContain("Base system prompt");
expect(event.system[0]!.text).toContain("## Plan Submission");
expect(event.system[0]!.text.indexOf("Base system prompt"))
.toBeLessThan(event.system[0]!.text.indexOf("## Plan Submission"));
});
});
describe("system part consolidation (#1114 regression class)", () => {
// The bug class flagged in #1114's review: truncating the system array
// BEFORE composing silently drops the host's entire system prompt. These
// fail if either helper is reordered to `system.length = 0` first.
test("replacePlanningSystemParts composes existing text before truncating", () => {
const system = [
{ type: "text" as const, text: "Host base rules" },
{ type: "text" as const, text: "STRICTLY FORBIDDEN: ANY file edits.\nKeep plans concise." },
];
replacePlanningSystemParts(system, ["## Plannotator planning prompt"]);
expect(system.length).toBe(1);
const text = system[0]!.text;
// Pre-existing prompt text survives the consolidation (compose ran first).
expect(text).toContain("Host base rules");
expect(text).toContain("Keep plans concise.");
expect(text).toContain("## Plannotator planning prompt");
expect(text.indexOf("Host base rules")).toBeLessThan(text.indexOf("Keep plans concise."));
expect(text.indexOf("Keep plans concise.")).toBeLessThan(text.indexOf("## Plannotator planning prompt"));
// Conflicting plan-mode rules are still stripped.
expect(text).not.toContain("STRICTLY FORBIDDEN");
});
test("pushComposedSystemReminder keeps prior parts' text before the reminder", () => {
const system = [
{ type: "text" as const, text: "Host base rules" },
{ type: "text" as const, text: "Second host part" },
];
pushComposedSystemReminder(system, "## Plan Submission reminder");
expect(system.length).toBe(1);
const text = system[0]!.text;
expect(text).toContain("Host base rules");
expect(text).toContain("Second host part");
expect(text.endsWith("## Plan Submission reminder")).toBe(true);
expect(text.indexOf("Host base rules")).toBeLessThan(text.indexOf("Second host part"));
});
});
describe("V2 plan review URL delivery", () => {
const SESSION_URL = "http://127.0.0.1:19432";
// Regression: only the slash-command path was fixed at first. The plan path
// builds its own client, so a remote reviewer who reached the review through
// submit_plan still saw nothing: no browser is opened for them, and the
// plugin's console output is discarded by the host.
test("the embedded plan path posts the session URL as a transcript notice", async () => {
const synthetic = mock(async (_input: unknown) => ({}));
const client = createV2BridgeClient({
ctx: { session: { synthetic } } as never,
getAgents: async () => [],
sessionID: "session-1",
});
createPlanReadyNotifier(client)(SESSION_URL);
await Promise.resolve();
expect(synthetic).toHaveBeenCalledTimes(1);
expect(synthetic.mock.calls[0]![0]).toMatchObject({
sessionID: "session-1",
description: formatSessionUrlNotice(SESSION_URL),
resume: false,
});
});
// Regression: the fallback must stay SILENT, not fall back to app.log. That
// is console.error, the same stderr handleServerReady has already printed the
// URL to, so logging here would duplicate the line in remote mode and add a
// stray one locally. This hook was empty for exactly that reason.
test("without session.synthetic the plan path stays silent", () => {
const log = mock((_entry: unknown) => {});
const client = createV2BridgeClient({
ctx: { session: {} },
getAgents: async () => [],
sessionID: "session-1",
});
client.app.log = log as never;
expect(client.notifyUrl).toBeUndefined();
expect(() => createPlanReadyNotifier(client)(SESSION_URL)).not.toThrow();
expect(log).not.toHaveBeenCalled();
});
// Regression: the notifier tests above all pass while the plan path itself is
// wired to nothing, which is exactly the shape the bug had. Reaching the real
// wiring means running a plan review, so these two facts are pinned at source
// level instead: without the session id the client can build no notifier, and
// without the ready hook nothing ever calls it. Either one silently restores
// the invisible URL with no other symptom.
test("the plan path threads the session id and drives the ready hook", () => {
const source = readFileSync(path.join(import.meta.dir, "server.ts"), "utf-8");
// Booleans, not toMatch: a failing regex against a whole source file dumps
// the file into the report and buries the one line that matters.
expect(/sessionID:\s*toolContext\.sessionID/.test(source)).toBe(true);
expect(/logReady:\s*createPlanReadyNotifier\(/.test(source)).toBe(true);
});
// Regression: a rejected notice must not surface as an unhandled rejection
// and must not take the plan review down with it.
test("a rejecting notice is caught", async () => {
const client = createV2BridgeClient({
ctx: { session: { synthetic: async () => { throw new Error("session gone"); } } } as never,
getAgents: async () => [],
sessionID: "session-1",
});
expect(() => createPlanReadyNotifier(client)(SESSION_URL)).not.toThrow();
await Promise.resolve();
await Promise.resolve();
});
});