mirror of
https://github.com/obra/superpowers.git
synced 2026-11-07 23:48:10 +00:00
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
98 lines
3.4 KiB
JavaScript
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');
|