Files
backnotprop__plannotator/apps/opencode-plugin/native-commands.test.ts
Michael Ramos 82a8f236ec feat(opencode): restore the slash commands on OpenCode 2 (#1434)
* feat(opencode): restore the slash commands on OpenCode 2

OpenCode's V2 plugin API gained native command execution upstream
(anomalyco/opencode issue #2185, PR #44765): ctx.command.transform lets a
plugin add a command whose execute callback fully owns the invocation. That
shape currently ships on the beta and dev dist-tags of @opencode-ai/plugin
while next and latest still carry the older context, so the capability is
duck-typed at runtime and never imported. On a host that exposes it the V2
adapter registers /plannotator-review, /plannotator-annotate and
/plannotator-last and runs the same handleCliCommand machinery OpenCode 1
uses, passing the raw argument tail straight through to the CLI. On a host
without it nothing new is registered and behavior is byte-identical to before.

Also wires ctx.session.switchAgent (same API generation, same probe) so an
agent switch chosen in the review UI is applied instead of only warned about,
and accepts both agent.list() response shapes: the HTTP client types it as a
{ location, data } envelope while the in-process plugin domain answers with a
bare array, where reading .data threw and silently emptied the agent list.

The shared command stubs get model-mediated fallback bodies for OpenCode 2
hosts on the stale channels. They carry no shell interpolation on purpose:
OpenCode 1 evaluates a template's !`...` before the V1 plugin's
command.execute.before hook can clear the parts, so a bang template there
would launch a second Plannotator session on every OC1 invocation. A source
level test pins that.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): probe the command draft and reclaim the names from the stubs

Review found the capability probe was wrong in the direction that matters.
ctx.command.transform exists on pre-#44765 hosts too: our own pinned
@opencode-ai/plugin@0.0.0-next-16775 declares CommandDraft as
{ list, get, update, remove } with no add. The probe therefore returned true on
next and latest, draft.add was undefined, and because transforms are stored and
replayed the TypeError landed in the batched reload flush and aborted it before
commit, plausibly taking every command registration on the host down with it.
Capability is now read from the draft handed to the callback, which is the only
witness, and the registration call is wrapped so no transform rejection can fail
plugin setup.

The stubs also shadowed the native definitions on new hosts. Command definitions
land in a name-keyed map where add is Map.set, transforms replay in registration
order, and OpenCode's own ConfigCommandPlugin activates in the post group after
package plugins while scanning the exact directory the installer writes the
three stubs to. A setup-time registration is therefore always overwritten on a
normal install. The plugin now re-registers the same transform once activation
settles, so its definitions are last in the replay order, and calls
ctx.command.reload() explicitly because a late registration only adds its reload
to the already-flushed boot batch. Ownership is read back from
ctx.command.list() by description, which is why the native descriptions and the
stub frontmatter are deliberately distinct. If the reclaim cannot run the stubs
keep the names and the commands still work through their fallback bodies.

Also: a failing switchAgent no longer costs the reviewer their feedback on the
command path, feedback is delivered as "queue" rather than replaying the
invocation's admission mode minutes later when a steer would land mid-turn, and
the agent-list comment no longer asserts a bare-array response that could not be
reproduced upstream (accepting both shapes is still right, since reading .data
blindly throws into a catch that degrades silently).

Tests: the real old-host draft shape registers nothing and throws nothing, the
shadowing contest is modelled against upstream's replay semantics, the OpenCode 1
parts-clearing invariant is pinned for all three commands in both plan-agent and
manual mode now that the stubs carry real instructions, and the V2 smoke asserts
the plugin did not activate as failed and that all three commands resolve. The
smoke now also installs the stubs into its sandbox config dir so the contest
actually happens there. scripts/opencode2-native-commands-smoke.sh runs the same
smoke against a dev-channel build with native commands required; CI cannot,
because it pins a next build.

AI-assisted (Claude) under maintainer direction.

* fix(opencode): keep the reclaim ticking and stop an unbuilt checkout failing setup

The reclaim ended the loop when the draft-probe flag read false, but that flag
only flips when the transform replays, which under boot batching is the flush
after every plugin has loaded. Plannotator loads before the post-group config
plugins, so the first tick legitimately reads false and the loop exited for
good: the reclaim was inert in exactly the shape production has. The tick is
skipped now instead, with a test that flips the flag between ticks.

The V1 entry called resolveBundledHtmlPath synchronously during plugin
construction, outside the .catch that was there to absorb a missing asset, so an
unbuilt checkout threw out of construction before any code path that needs the
HTML. The Test workflow runs bun test with no build step, so the new OpenCode 1
interception tests failed there. Both preloads are guarded; the lazy getters
still raise a clear error if something actually needs the file.

The smoke's failed-plugin guard read entry.state.status, but Plugin.Info carries
status and error at the top level, so a failed activation slipped through.
Reads the top level first and keeps the nested one as a fallback.

Comment corrections: State.batch clears its active flag before flushing, so a
late transform registration materializes on its own; the explicit reload() is
redundant-but-defensive rather than required. The reclaim schedule is a list of
deltas the loop awaits in turn, so the ticks land near 0.3s, 1.5s, 5.5s and
15.5s, not at the raw numbers.

AI-assisted (Claude) under maintainer direction.
2026-08-31 10:42:26 -07:00

476 lines
18 KiB
TypeScript

import { describe, expect, mock, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";
import {
NATIVE_COMMANDS,
reclaimNativeCommands,
registerNativeCommands,
type CliCommandRequest,
} from "./native-commands";
import { createV2BridgeClient, normalizeAgentList, readListPayload, toBridgeMessages } from "./v2-client";
import { switchV2SessionAgent } from "./agent-switch";
const STUB_DIR = path.join(import.meta.dir, "commands");
/** The pre-#44765 draft: `transform` exists, `add` does not. */
function legacyDraft() {
return { list: () => [], get: () => undefined, update: () => {}, remove: () => {} };
}
function makeDeps(overrides: Record<string, unknown> = {}) {
const runCommand = mock(async (_request: CliCommandRequest) => {});
const added: Array<{ name: string; description?: string; execute: Function }> = [];
const transform = mock(async (apply: (draft: { add: (d: any) => void }) => void) => {
apply({ add: (definition) => added.push(definition) });
return { dispose: async () => {} };
});
const ctx: any = {
// No list/reload here on purpose: the reclaim loop then exits before its
// first wait, so these tests never schedule a timer.
command: { transform },
session: { get: async () => ({ location: { directory: "/project" } }) },
location: { directory: "/fallback" },
...overrides,
};
return {
ctx,
added,
transform,
runCommand,
deps: {
ctx,
getAgents: async () => [],
getBridgeContext: async () => ({ sharingEnabled: true }),
runCommand,
},
};
}
/**
* A faithful stand-in for OpenCode's command state: transforms are appended and
* REPLAYED in registration order, and `add` is a `Map.set`, so the last
* transform to add a name wins (core/src/state.ts, core/src/command.ts).
*/
function makeCommandHost() {
const committed = new Map<string, { name: string; description?: string }>();
const transforms: Array<(draft: any) => void> = [];
const materialize = () => {
committed.clear();
for (const transform of transforms) transform({ add: (d: any) => committed.set(d.name, d) });
};
return {
committed,
domain: {
transform: async (apply: (draft: any) => void) => {
transforms.push(apply);
materialize();
return { dispose: async () => {} };
},
list: async () => ({
location: {},
data: [...committed.values()].map(({ name, description }) => ({ name, description })),
}),
reload: async () => { materialize(); },
},
/** Stand-in for OpenCode's ConfigCommandPlugin, which activates after us. */
addConfigStubs: () => {
transforms.push((draft: any) => {
for (const command of NATIVE_COMMANDS) {
draft.add({ name: command.name, description: "from the markdown stub", execute: async () => {} });
}
});
materialize();
},
};
}
describe("OpenCode 2 native command registration", () => {
test("registers nothing when the host has no command domain", async () => {
const { deps } = makeDeps({ command: undefined });
expect(await registerNativeCommands(deps)).toBe(false);
});
test("registers nothing on a pre-#44765 draft that has no add", async () => {
// The real old-host shape. `ctx.command.transform` EXISTS on `next` and
// `latest`; only the draft tells the truth. Calling a missing `add` here
// would throw inside the batched reload flush and abort it before commit,
// taking every command registration down with it.
let applied = false;
const { deps } = makeDeps({
command: {
transform: async (apply: (draft: any) => void) => {
applied = true;
apply(legacyDraft());
return { dispose: async () => {} };
},
},
});
expect(await registerNativeCommands(deps)).toBe(false);
expect(applied).toBe(true);
});
test("registers exactly the three Plannotator commands when the draft supports add", async () => {
const { deps, added } = makeDeps();
expect(await registerNativeCommands(deps)).toBe(true);
// Command names are the user-visible slash commands and are deliberately
// frozen: they must match the OpenCode 1 stubs so both hosts agree.
expect(added.map((command) => command.name)).toEqual([
"plannotator-review",
"plannotator-annotate",
"plannotator-last",
]);
for (const command of added) expect(command.execute).toBeInstanceOf(Function);
});
test("each execute runs the CLI path with the raw argument tail", async () => {
const { deps, added, runCommand } = makeDeps();
await registerNativeCommands(deps);
const annotate = added.find((command) => command.name === "plannotator-annotate")!;
await annotate.execute({
sessionID: "session-9",
prompt: { text: "notes.md --gate --json" },
delivery: "steer",
});
expect(runCommand).toHaveBeenCalledTimes(1);
const request = runCommand.mock.calls[0]![0]!;
expect(request.command).toBe("plannotator-annotate");
expect(request.sessionId).toBe("session-9");
// Raw pass-through: flags must reach the CLI's own argument resolution
// unparsed, exactly as OpenCode 1 forwards `input.arguments`.
expect(request.rawArgs).toBe("notes.md --gate --json");
expect(request.cwd).toBe("/project");
});
test("an argument-less invocation still runs with an empty tail", async () => {
const { deps, added, runCommand } = makeDeps();
await registerNativeCommands(deps);
const review = added.find((command) => command.name === "plannotator-review")!;
await review.execute({ sessionID: "session-1" });
expect(runCommand.mock.calls[0]![0]!.rawArgs).toBe("");
});
test("falls back to the plugin location when the session has no directory", async () => {
const { deps, added, runCommand } = makeDeps({
session: { get: async () => { throw new Error("no session"); } },
});
await registerNativeCommands(deps);
await added[0]!.execute({ sessionID: "session-1", prompt: { text: "" } });
expect(runCommand.mock.calls[0]![0]!.cwd).toBe("/fallback");
});
test("a failing command is reported, not rethrown into OpenCode", async () => {
const failing = mock(async () => { throw new Error("boom"); });
const { deps, added } = makeDeps();
const errors: unknown[] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => { errors.push(args[0]); };
try {
await registerNativeCommands({ ...deps, runCommand: failing });
await added[0]!.execute({ sessionID: "session-1", prompt: { text: "" } });
} finally {
console.error = originalError;
}
expect(errors.some((line) => String(line).includes("boom"))).toBe(true);
});
});
describe("reclaiming the command names from the config-loaded stubs", () => {
// OpenCode activates its own ConfigCommandPlugin AFTER package plugins, and
// it replays the installed markdown stubs into the same name-keyed map, so a
// setup-time registration is always overwritten on a normal install.
test("re-registers after the config stubs shadow the native definitions", async () => {
const host = makeCommandHost();
const { deps } = makeDeps({ command: host.domain });
const apply = () => registerNativeCommands(deps).then(() => {});
await apply();
expect(host.committed.get("plannotator-review")?.description).toBe(NATIVE_COMMANDS[0]!.description);
host.addConfigStubs();
expect(host.committed.get("plannotator-review")?.description).toBe("from the markdown stub");
await reclaimNativeCommands({
ctx: deps.ctx,
apply,
isSupported: () => true,
wait: async () => {},
});
for (const command of NATIVE_COMMANDS) {
expect(host.committed.get(command.name)?.description).toBe(command.description);
}
});
test("a reload after the reclaim keeps the native definitions", async () => {
// Config only ever calls reload() afterwards; replay order is stable, so
// winning once must mean winning permanently.
const host = makeCommandHost();
const { deps } = makeDeps({ command: host.domain });
const apply = () => registerNativeCommands(deps).then(() => {});
await apply();
host.addConfigStubs();
await reclaimNativeCommands({ ctx: deps.ctx, apply, isSupported: () => true, wait: async () => {} });
await host.domain.reload();
expect(host.committed.get("plannotator-review")?.description).toBe(NATIVE_COMMANDS[0]!.description);
});
test("stops re-registering once ownership outlives a reclaim", async () => {
const host = makeCommandHost();
const { deps } = makeDeps({ command: host.domain });
let applies = 0;
const apply = async () => { applies += 1; await registerNativeCommands(deps); };
await apply();
host.addConfigStubs();
applies = 0;
await reclaimNativeCommands({ ctx: deps.ctx, apply, isSupported: () => true, wait: async () => {} });
// One reclaim, then the next tick confirms ownership and the loop exits
// instead of piling on a transform per tick.
expect(applies).toBe(1);
});
test("keeps ticking while the draft probe has not run yet", async () => {
// The probe flag only flips when the transform REPLAYS, which under boot
// batching is at the flush after every plugin has loaded, and Plannotator
// loads before the post-group config plugins. An early tick that reads
// false must skip, not end the loop, or the reclaim is inert in exactly
// the shape production has.
const host = makeCommandHost();
const { deps } = makeDeps({ command: host.domain });
const apply = () => registerNativeCommands(deps).then(() => {});
await apply();
host.addConfigStubs();
let ticks = 0;
await reclaimNativeCommands({
ctx: deps.ctx,
apply,
// False on the first tick, true from the second: the host flushed.
isSupported: () => ticks > 1,
wait: async () => { ticks += 1; },
});
expect(host.committed.get("plannotator-review")?.description).toBe(NATIVE_COMMANDS[0]!.description);
});
test("does nothing on a host without list or reload, and never on an unsupported draft", async () => {
const apply = mock(async () => {});
await reclaimNativeCommands({
ctx: { command: { transform: async () => ({}) } },
apply,
isSupported: () => true,
wait: async () => {},
});
const host = makeCommandHost();
await reclaimNativeCommands({
ctx: { command: host.domain },
apply,
isSupported: () => false,
wait: async () => {},
});
expect(apply).not.toHaveBeenCalled();
});
test("a throwing list read ends the reclaim instead of looping", async () => {
const apply = mock(async () => {});
await reclaimNativeCommands({
ctx: {
command: {
transform: async () => ({}),
list: async () => { throw new Error("no service"); },
reload: async () => {},
},
},
apply,
isSupported: () => true,
wait: async () => {},
});
expect(apply).not.toHaveBeenCalled();
});
});
describe("V2 list shapes", () => {
test("reads an agent list as a bare array or a { data } envelope", () => {
const entries = [{ id: "plan", mode: "primary", hidden: false }];
expect(normalizeAgentList(entries)).toEqual([
{ name: "plan", description: undefined, mode: "primary", hidden: false },
]);
expect(normalizeAgentList({ location: {}, data: entries })).toEqual(normalizeAgentList(entries));
});
test("unusable responses degrade to an empty list instead of throwing", () => {
expect(normalizeAgentList(undefined)).toEqual([]);
expect(normalizeAgentList({ data: "nope" })).toEqual([]);
expect(normalizeAgentList([{ mode: "primary" }])).toEqual([]);
expect(readListPayload({ data: [{ description: "nameless" }] })).toEqual([]);
});
});
describe("V2 agent switching", () => {
test("switches the session agent when the host exposes switchAgent", async () => {
const switchAgent = mock(async (_input: { sessionID: string; agent: string }) => {});
const result = await switchV2SessionAgent({
ctx: { session: { switchAgent } },
sessionID: "session-1",
requestedAgent: "build",
getAgents: async () => [{ name: "build" }],
warn: () => {},
});
expect(switchAgent).toHaveBeenCalledWith({ sessionID: "session-1", agent: "build" });
expect(result).toBe("build");
});
test("warns and leaves the agent alone when the host has no switchAgent", async () => {
const warnings: string[] = [];
const result = await switchV2SessionAgent({
ctx: { session: {} },
sessionID: "session-1",
requestedAgent: "build",
getAgents: async () => [{ name: "build" }],
warn: (message) => warnings.push(message),
});
expect(result).toBeUndefined();
expect(warnings).toHaveLength(1);
});
test("a failing switch does not fail the approval", async () => {
const warnings: string[] = [];
const result = await switchV2SessionAgent({
ctx: { session: { switchAgent: async () => { throw new Error("busy"); } } },
sessionID: "session-1",
requestedAgent: "build",
getAgents: async () => [{ name: "build" }],
warn: (message) => warnings.push(message),
});
expect(result).toBeUndefined();
expect(warnings.some((line) => line.includes("busy"))).toBe(true);
});
test("an unavailable or disabled agent never reaches switchAgent", async () => {
const switchAgent = mock(async () => {});
expect(await switchV2SessionAgent({
ctx: { session: { switchAgent } },
sessionID: "session-1",
requestedAgent: "ghost",
getAgents: async () => [{ name: "build" }],
warn: () => {},
})).toBeUndefined();
expect(await switchV2SessionAgent({
ctx: { session: { switchAgent } },
sessionID: "session-1",
requestedAgent: "disabled",
getAgents: async () => [{ name: "build" }],
warn: () => {},
})).toBeUndefined();
expect(switchAgent).not.toHaveBeenCalled();
});
});
describe("V2 feedback delivery", () => {
function makeBridge(switchAgent: (input: { sessionID: string; agent: string }) => Promise<unknown>) {
const prompt = mock(async (_input: unknown) => ({}));
const warnings: string[] = [];
const client = createV2BridgeClient({
ctx: { session: { prompt, switchAgent } },
getAgents: async () => [],
warn: (message) => warnings.push(message),
});
return { client, prompt, warnings };
}
test("a failing switchAgent still delivers the feedback", async () => {
// Same guarantee the approval path gives: the reviewer's words must not be
// lost because the session refused to change agent.
const { client, prompt, warnings } = makeBridge(async () => { throw new Error("busy"); });
await client.session.prompt({
path: { id: "session-1" },
body: { agent: "build", parts: [{ type: "text", text: "please fix" }] },
});
expect(prompt).toHaveBeenCalledTimes(1);
expect(prompt.mock.calls[0]![0]).toMatchObject({ sessionID: "session-1", text: "please fix" });
expect(warnings.some((line) => line.includes("busy"))).toBe(true);
});
test("feedback is queued, never steered into a running turn", async () => {
// The invocation's own delivery was chosen at admission; a review comes
// back minutes later, when a steer would land mid-turn.
const { client, prompt } = makeBridge(async () => {});
await client.session.prompt({
path: { id: "session-1" },
body: { parts: [{ type: "text", text: "LGTM" }] },
});
expect(prompt.mock.calls[0]![0]).toMatchObject({ delivery: "queue" });
});
});
describe("V2 session context translation", () => {
// `/plannotator-last` reads assistant text out of the session. V2 messages
// are flat (`{ id, type, content }`) where V1 nested them under info/parts;
// getRecentAssistantMessages reads the V1 shape.
test("maps flat V2 messages into the nested shape the bridge reads", () => {
const mapped = toBridgeMessages([
{ id: "m1", type: "assistant", time: { created: 5 }, content: [{ type: "text", text: "hi" }] },
]) as Array<{ info: { id: string; role: string; time: { created: number } }; parts: unknown[] }>;
expect(mapped[0]!.info).toEqual({ id: "m1", role: "assistant", time: { created: 5 } });
expect(mapped[0]!.parts).toEqual([{ type: "text", text: "hi" }]);
});
test("a non-array context yields no messages", () => {
expect(toBridgeMessages(undefined)).toEqual([]);
});
});
describe("shared command stubs", () => {
function readStub(name: string): { frontmatter: string; body: string } {
const source = readFileSync(path.join(STUB_DIR, `${name}.md`), "utf-8");
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(source);
if (!match) throw new Error(`${name}.md has no frontmatter`);
return { frontmatter: match[1]!, body: match[2]! };
}
for (const command of NATIVE_COMMANDS) {
// OpenCode 1 evaluates a command template's shell interpolation BEFORE the
// V1 plugin's command.execute.before hook can clear the parts, so a `!`
// backtick in these shared stubs would launch a second Plannotator session
// on every OC1 invocation. Permanently pinned.
test(`${command.name}.md carries no shell interpolation`, () => {
const { body } = readStub(command.name);
expect(body).not.toContain("!`");
// The model-mediated fallback needs the argument tail to reach the CLI.
expect(body).toContain("$ARGUMENTS");
});
// The reclaim tells our definition from the config-loaded stub by reading
// the description back out of ctx.command.list(). Identical descriptions
// would make that check always report ownership and silently disable it.
test(`${command.name} native description differs from the stub frontmatter`, () => {
const { frontmatter } = readStub(command.name);
expect(frontmatter).toContain("description:");
expect(frontmatter).not.toContain(command.description);
});
}
});