superpowers/tests/opencode/test-hook-registration.mjs
Jesse Vincent 80b085cfb3 fix(opencode): inject the V2 bootstrap on compaction and generate requests
The V2 plugin registered its bootstrap injector only on
ctx.session.hook('context'). OpenCode 2 prepares each model request kind
through its own hook: normal agent turns through `context`, the
compaction (summary) request through `compaction`, and session-scoped
one-shot generation through `generate`
(packages/core/src/session/model-request.ts). Every built-in V2 plugin
registers its handler on all three, and the upstream V1 migration guide
says to do the same when a transform must apply to every request kind.

So the compaction request's first user message lacked the bootstrap
that every prior turn carried, and the prompt diverged right after
system + tools. On a provider with prefix caching that is a cold
re-prefill of the whole session on its largest request (#2391).

Reproduced on real OpenCode 2.1.2 and 2.1.3 with a scripted stub model
that pushes the session over the compaction ceiling: before, the
compaction request shares 26.5k of 30.8k serialized chars with the
previous agent request and differs at the first user message; after,
the previous request is a full prefix of the compaction request.

The handler body is unchanged (child-session skip, idempotence guard,
checkpoint-only append), lifted into a named function and registered on
all three hooks, each in its own try/catch. Based on PR #2409.

Fixes #2391

Co-authored-by: Aashish <145881415+aashish254@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_01SHANcr4A71nzuU9et4Fnba
2026-10-22 22:55:41 +00:00

98 lines
3.4 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import { pathToFileURL } from 'node:url';
// Issue #2391: the V2 plugin must register bootstrap injection on
// `context`, `compaction`, and `generate`. OpenCode dispatches each request
// shape through a different hook, so a handler on `context` only means
// compaction and generate requests diverge from the cached prefix at the
// tail — a cold re-prefill of the whole session on the largest request of
// its lifetime.
const [, , inputPath] = process.argv;
assert.ok(inputPath, 'pass the plugin module path');
const pluginURL = pathToFileURL(fs.realpathSync(inputPath));
const marker = '<EXTREMELY_IMPORTANT>\nYou have superpowers.';
let generation = 0;
async function captureHooks(fetchSession) {
const mod = await import(`${pluginURL.href}?hooks-test=${++generation}`);
const hooks = new Map();
await mod.default.setup({
skill: { transform: async (transform) => transform({ add: () => {} }) },
session: {
get: ({ sessionID }) => fetchSession(sessionID),
hook: async (name, callback) => { hooks.set(name, callback); },
},
});
return hooks;
}
function event(sessionID) {
return {
sessionID,
messages: [{ role: 'user', content: [{ type: 'text', text: 'continue' }] }],
};
}
const session = (id) => ({ id });
// All three request hooks must be registered.
for (const required of ['context', 'compaction', 'generate']) {
const hooks = await captureHooks(async (id) => session(id));
assert.ok(hooks.has(required), `plugin registers '${required}' hook`);
}
// One shared handler: the three hooks resolve to the same function so
// behaviour cannot drift between request shapes.
const shared = await captureHooks(async (id) => session(id));
assert.equal(
shared.get('compaction'),
shared.get('context'),
'compaction handler is the context handler',
);
assert.equal(
shared.get('generate'),
shared.get('context'),
'generate handler is the context handler',
);
// Each hook actually injects the bootstrap into an eligible session.
for (const name of ['context', 'compaction', 'generate']) {
const hooks = await captureHooks(async (id) => session(id));
const ev = event(`${name}-root`);
await hooks.get(name)(ev);
assert.equal(
ev.messages[0].content.filter((p) => p.text?.startsWith(marker)).length,
1,
`'${name}' hook injects bootstrap exactly once`,
);
}
// Child sessions stay suppressed on every hook (regression guard against
// only 'context' checking parentID while compaction/generate skip the check).
for (const name of ['context', 'compaction', 'generate']) {
const hooks = await captureHooks(async (id) => ({ id, parentID: 'parent' }));
const ev = event(`${name}-child`);
await hooks.get(name)(ev);
assert.equal(
ev.messages[0].content.filter((p) => p.text?.startsWith(marker)).length,
0,
`'${name}' hook skips bootstrap for child sessions`,
);
}
// Idempotence: a second delivery of the same event does not double-inject.
for (const name of ['context', 'compaction', 'generate']) {
const hooks = await captureHooks(async (id) => session(id));
const ev = event(`${name}-root`);
await hooks.get(name)(ev);
await hooks.get(name)(ev);
assert.equal(
ev.messages[0].content.filter((p) => p.text?.startsWith(marker)).length,
1,
`'${name}' hook stays idempotent on repeat`,
);
}
console.log('V2 hook registration (context/compaction/generate) passed');