diff --git a/README.md b/README.md index f7a5dc8bc..32b62d3d6 100644 --- a/README.md +++ b/README.md @@ -1919,6 +1919,8 @@ New-Item -ItemType Directory -Force .opencode/plugins | Out-Null Copy-Item integrations/opencode/.opencode/plugins/token-optimizer.js .opencode/plugins/ ``` +On OpenCode 1.3.3 or earlier (check `opencode --version`), copy `integrations/opencode/legacy/.opencode/plugins/token-optimizer.js` to the same place instead. Those releases call every export of a plugin file as a plugin function, so they cannot load the current file, whose default export is the object OpenCode 2.x requires. Both files have the same name, so copying one replaces the other. + The plugin preserves Token Optimizer usage state in OpenCode's compaction prompt. Under `TOKEN_OPTIMIZER_MODE=enforce` its `tool.execute.before` hook rejects full-file reads over 25 KB and steers the agent to `smart_read`; the default `assist` lets them through. Small and partial reads always pass normally, and `TOKEN_OPTIMIZER_MODE=advise` gives non-blocking guidance. Restart OpenCode after adding the plugin. See the official [OpenCode MCP guide](https://opencode.ai/docs/mcp-servers/) and [plugin hook guide](https://opencode.ai/docs/plugins/). ### Generic MCP configuration diff --git a/integrations/README.md b/integrations/README.md index 1151da39c..bda1730d7 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -135,6 +135,12 @@ cp -R opencode/hooks/* /path/to/project/.opencode/hooks/token-optimizer/ The local plugin invokes the generated shared-engine entries, preserves guidance during compaction, and appends applicable graph findings to tool results. +On OpenCode 1.3.3 or earlier, copy +`opencode/legacy/.opencode/plugins/token-optimizer.js` to the same destination +instead. Those releases call every export of a plugin file as a plugin function +and cannot load the current file, whose default export is the object OpenCode +2.x requires. The two files share a name, so copying one replaces the other. + ## Hook behavior and controls All adapters are fail-open for malformed payloads, missing files, small files, diff --git a/integrations/opencode/.opencode/plugins/token-optimizer.js b/integrations/opencode/.opencode/plugins/token-optimizer.js index 51217e0fb..af79f04cd 100644 --- a/integrations/opencode/.opencode/plugins/token-optimizer.js +++ b/integrations/opencode/.opencode/plugins/token-optimizer.js @@ -1,5 +1,17 @@ import { spawnSync } from 'node:child_process'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; + +/** + * Runtime used to execute the generated hook entries. The hooks target Node, + * and OpenCode 1.x ran plugins inside a Node server, so process.execPath used + * to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path + * makes the CLI treat the script as a project directory (ENOTDIR) instead of + * executing it, and enforcement silently no-ops. Keep process.execPath when it + * is Node and fall back to node on PATH otherwise. + */ +const HOOK_RUNTIME = /^node/i.test(basename(process.execPath)) + ? process.execPath + : 'node'; const compactionGuidance = ` ## Token Optimizer state @@ -10,13 +22,16 @@ files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output, and get_optimization_report when savings are requested. `; -export const TokenOptimizerPlugin = async ({ directory }) => { +/** + * Spawn one generated shared hook entry over the stdin/stdout JSON contract. + * Shared by both plugin APIs below so V1 and V2 hosts enforce identically. + */ +const createInvoke = (directory) => { const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer'); - const pending = new Map(); - const invoke = (entry, payload) => { + return (entry, payload) => { try { const result = spawnSync( - process.execPath, + HOOK_RUNTIME, [join(hooks, `${entry}.mjs`)], { input: JSON.stringify(payload), @@ -36,6 +51,16 @@ export const TokenOptimizerPlugin = async ({ directory }) => { return null; } }; +}; + +/** + * OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape + * outright ("Plugin must export a default definition with an id and an effect + * or setup function"), so 2.x is served by a separate `setup` instead. + */ +export const TokenOptimizerPlugin = async ({ directory }) => { + const invoke = createInvoke(directory); + const pending = new Map(); const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`; const payloadFor = (input, args) => ({ session_id: input.sessionID || 'default', @@ -82,3 +107,99 @@ export const TokenOptimizerPlugin = async ({ directory }) => { }, }; }; + +// LEGACY CUT. scripts/generate-opencode-legacy-plugin.mjs copies everything +// above this line to +// integrations/opencode/legacy/.opencode/plugins/token-optimizer.js. +// OpenCode 1.3.3 and earlier call EVERY export as a plugin function, so the +// default object below makes them fail to load the whole file, while OpenCode +// 2.x accepts nothing but that object. No one module satisfies both loaders. + +/** + * OpenCode V2 entrypoint. V2 loads plugins from a default export with an `id` + * and a `setup(ctx)` that registers hooks through the V2 hook domains + * (`session.hook` / `tool.hook`) instead of returning a hook-name + * map. `server` serves OpenCode 1.3.4 and later 1.x releases, which read a + * default `{ id, server }` object, so this file serves both plugin APIs from + * the same hook logic. + * + * V1 hook -> V2 hook mapping: + * experimental.chat.system.transform -> session.hook('context') + * tool.execute.before -> tool.hook('execute.before') + * tool.execute.after -> tool.hook('execute.after') + * experimental.session.compacting -> session.hook('compaction') + */ +export default { + id: 'token-optimizer', + server: TokenOptimizerPlugin, + async setup(ctx) { + const directory = ctx.location.directory; + const invoke = createInvoke(directory); + const pending = new Map(); + const payloadFor = (event, toolInput) => ({ + session_id: event.sessionID || 'default', + cwd: directory, + tool_name: event.tool, + tool_input: toolInput || {}, + }); + + // V1 experimental.chat.system.transform: inject the session-start policy + // into every agent-loop model request. + await ctx.session.hook('context', (event) => { + const result = invoke('session-start', {}); + const policy = result?.hookSpecificOutput?.additionalContext; + if (policy) event.system.push({ type: 'text', text: policy }); + }); + + // V1 tool.execute.before: deny oversized reads before they run and + // remember the pre-tool context for the matching execute.after. + await ctx.tool.hook('execute.before', async (event) => { + const shared = invoke('pre-tool', payloadFor(event, event.input)); + const hook = shared?.hookSpecificOutput; + if (hook?.permissionDecision === 'deny') { + throw new Error(hook.permissionDecisionReason); + } + pending.set(event.id, { + context: hook?.additionalContext || '', + input: event.input, + }); + }); + + // V1 tool.execute.after: append the captured context and the post-tool + // graph knowledge to the tool result the model actually receives. V2 + // distinguishes completed from failed calls; a failed call has no result + // to append to, so the graph context is dropped there instead of crashing. + await ctx.tool.hook('execute.after', async (event) => { + const parts = []; + const prior = pending.get(event.id); + pending.delete(event.id); + if (prior?.context) parts.push(prior.context); + + if (/^(?:edit|write|apply_patch|replace)$/i.test(event.tool)) { + const result = invoke( + 'post-tool', + payloadFor(event, prior?.input ?? event.input) + ); + const context = result?.hookSpecificOutput?.additionalContext; + if (context) parts.push(context); + } + if (parts.length && event.status === 'completed') { + const appended = `[Token Optimizer graph]\n${parts.join('\n\n')}`.trim(); + const content = event.result.content; + if (typeof content === 'string') { + event.result.content = `${content}\n\n${appended}`.trim(); + } else if (Array.isArray(content)) { + event.result.content = [...content, { type: 'text', text: appended }]; + } else { + event.result.content = appended; + } + } + }); + + // V1 experimental.session.compacting: keep the optimizer state guidance + // in the compaction summary request. + await ctx.session.hook('compaction', (event) => { + event.system.push({ type: 'text', text: compactionGuidance }); + }); + }, +}; diff --git a/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js b/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js new file mode 100644 index 000000000..ec615ce55 --- /dev/null +++ b/integrations/opencode/legacy/.opencode/plugins/token-optimizer.js @@ -0,0 +1,114 @@ +// GENERATED from integrations/opencode/.opencode/plugins/token-optimizer.js by +// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run +// `npm run sync:hooks`. For OpenCode 1.3.3 and earlier only: it exports the +// V1 plugin function and nothing else, because those releases call every +// export as a plugin. Install this file OR the current one, never both. +import { spawnSync } from 'node:child_process'; +import { basename, join } from 'node:path'; + +/** + * Runtime used to execute the generated hook entries. The hooks target Node, + * and OpenCode 1.x ran plugins inside a Node server, so process.execPath used + * to be node. OpenCode 2.x is a compiled binary: spawning it with a .mjs path + * makes the CLI treat the script as a project directory (ENOTDIR) instead of + * executing it, and enforcement silently no-ops. Keep process.execPath when it + * is Node and fall back to node on PATH otherwise. + */ +const HOOK_RUNTIME = /^node/i.test(basename(process.execPath)) + ? process.execPath + : 'node'; + +const compactionGuidance = ` +## Token Optimizer state + +Preserve which large files and bulky outputs have already been processed. After +compaction, continue using token-optimizer smart_read for large or repeated +files, smart_glob/smart_grep for noisy searches, optimize_text for bulky output, +and get_optimization_report when savings are requested. +`; + +/** + * Spawn one generated shared hook entry over the stdin/stdout JSON contract. + * Shared by both plugin APIs below so V1 and V2 hosts enforce identically. + */ +const createInvoke = (directory) => { + const hooks = join(directory, '.opencode', 'hooks', 'token-optimizer'); + return (entry, payload) => { + try { + const result = spawnSync( + HOOK_RUNTIME, + [join(hooks, `${entry}.mjs`)], + { + input: JSON.stringify(payload), + encoding: 'utf8', + // Preserve the live enforcement/capability contract for the generated + // child hook. Making this explicit also keeps test hosts and embedders + // from spawning with an implementation-defined environment snapshot. + env: { ...process.env }, + timeout: 5_000, + maxBuffer: 64 * 1024, + } + ); + return result.status === 0 && result.stdout.trim() + ? JSON.parse(result.stdout) + : null; + } catch { + return null; + } + }; +}; + +/** + * OpenCode V1 entrypoint: the V1 hook-name map. OpenCode 2.x rejects this shape + * outright ("Plugin must export a default definition with an id and an effect + * or setup function"), so 2.x is served by a separate `setup` instead. + */ +export const TokenOptimizerPlugin = async ({ directory }) => { + const invoke = createInvoke(directory); + const pending = new Map(); + const keyFor = (input) => input.callID || `${input.sessionID}:${input.tool}`; + const payloadFor = (input, args) => ({ + session_id: input.sessionID || 'default', + cwd: directory, + tool_name: input.tool, + tool_input: args || {}, + }); + + return { + 'experimental.chat.system.transform': async (_input, output) => { + const result = invoke('session-start', {}); + const policy = result?.hookSpecificOutput?.additionalContext; + if (policy) output.system.push(policy); + }, + 'tool.execute.before': async (input, output) => { + const shared = invoke('pre-tool', payloadFor(input, output.args)); + const hook = shared?.hookSpecificOutput; + if (hook?.permissionDecision === 'deny') { + throw new Error(hook.permissionDecisionReason); + } + pending.set(keyFor(input), { + context: hook?.additionalContext || '', + args: output.args, + }); + }, + 'tool.execute.after': async (input, output) => { + const parts = []; + const prior = pending.get(keyFor(input)); + pending.delete(keyFor(input)); + if (prior?.context) parts.push(prior.context); + + if (/^(?:edit|write|apply_patch|replace)$/i.test(input.tool)) { + const result = invoke('post-tool', payloadFor(input, prior?.args)); + const context = result?.hookSpecificOutput?.additionalContext; + if (context) parts.push(context); + } + if (parts.length) { + output.output = + `${output.output || ''}\n\n[Token Optimizer graph]\n${parts.join('\n\n')}`.trim(); + } + }, + 'experimental.session.compacting': async (_input, output) => { + output.context.push(compactionGuidance); + }, + }; +}; diff --git a/package.json b/package.json index 2e9feaa10..fa4b8c438 100644 --- a/package.json +++ b/package.json @@ -90,8 +90,8 @@ "validate": "node scripts/validate-package.js", "verify:package-contents": "node scripts/verify-package-contents.mjs", "verify:published-launch": "node scripts/verify-published-launch.mjs", - "sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs", - "sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check", + "sync:hooks": "node scripts/sync-hook-core.mjs && node scripts/generate-client-entries.mjs && node scripts/generate-opencode-legacy-plugin.mjs && node scripts/generate-client-configs.mjs && node scripts/pin-mcp-version.mjs", + "sync:hooks:check": "node scripts/sync-hook-core.mjs --check && node scripts/generate-client-entries.mjs --check && node scripts/generate-opencode-legacy-plugin.mjs --check && node scripts/generate-client-configs.mjs --check && node scripts/pin-mcp-version.mjs --check", "stamp:version": "node scripts/sync-hook-core.mjs --stamp && node scripts/generate-client-entries.mjs --stamp", "stamp:telemetry": "node scripts/stamp-telemetry-key.mjs", "verify:ui": "node scripts/verify-wiki-ui.mjs", diff --git a/scripts/generate-opencode-legacy-plugin.mjs b/scripts/generate-opencode-legacy-plugin.mjs new file mode 100644 index 000000000..561c73ac4 --- /dev/null +++ b/scripts/generate-opencode-legacy-plugin.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * Generates the OpenCode plugin for OpenCode 1.3.3 and earlier. + * + * Those releases call every export of a plugin file as a plugin function + * (packages/opencode/src/plugin/index.ts up to v1.3.3), and the current + * plugin's default export is the object OpenCode 2.x requires, so a release + * that old fails to load the whole file. No one module satisfies both loaders. + * The legacy build is the current plugin cut at its LEGACY CUT marker, so the + * two can never disagree about a hook. + */ + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + contentMatches, + normalizeEol, + readIfExists, + writeIfChanged, +} from './lib/text.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SOURCE = 'integrations/opencode/.opencode/plugins/token-optimizer.js'; +const LEGACY = 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js'; +const check = process.argv.includes('--check'); + +const text = normalizeEol(readFileSync(join(ROOT, SOURCE), 'utf8')); +const cut = text.indexOf('\n// LEGACY CUT.'); +if (cut < 0) { + console.error(`${SOURCE} has no LEGACY CUT marker`); + process.exit(1); +} +const contents = `// GENERATED from ${SOURCE} by +// scripts/generate-opencode-legacy-plugin.mjs -- edit that file, then run +// \`npm run sync:hooks\`. For OpenCode 1.3.3 and earlier only: it exports the +// V1 plugin function and nothing else, because those releases call every +// export as a plugin. Install this file OR the current one, never both. +${text.slice(0, cut).trimEnd()} +`; + +const destination = join(ROOT, LEGACY); +if (check) { + if (!contentMatches(readIfExists(destination), contents)) { + console.error(`DRIFT: ${LEGACY}\nRun: npm run sync:hooks`); + process.exit(1); + } + console.log('opencode legacy plugin in sync'); +} else { + writeIfChanged(destination, contents); + console.log(`generated ${LEGACY}`); +} diff --git a/scripts/verify-client-configs.mjs b/scripts/verify-client-configs.mjs index 36f8b805a..9456541e8 100644 --- a/scripts/verify-client-configs.mjs +++ b/scripts/verify-client-configs.mjs @@ -595,6 +595,50 @@ for (const [client, relative, required] of [ ); } +// OpenCode 2.x rejects V1 plugin implementations outright ("Plugin must export +// a default definition with an id and an effect or setup function"), so the +// OpenCode file must additionally default-export the V2 shape. Kilo stays on +// the V1 contract and is intentionally not part of this check. +{ + const path = join( + ROOT, + 'integrations/opencode/.opencode/plugins/token-optimizer.js' + ); + if (!existsSync(path)) { + check('opencode: V2 default export exists', false); + } else { + const raw = readFileSync(path, 'utf8'); + check('opencode: default-exports the V2 plugin object', raw.includes('export default')); + check( + 'opencode: V2 plugin declares its id', + raw.includes("id: 'token-optimizer'") + ); + check( + 'opencode: V2 setup registers the hook domains', + raw.includes("ctx.session.hook('context'") && + raw.includes("ctx.tool.hook('execute.before'") && + raw.includes("ctx.tool.hook('execute.after'") && + raw.includes("ctx.session.hook('compaction'") + ); + check( + 'opencode: V1 named export stays loadable', + raw.includes('export const TokenOptimizerPlugin') + ); + } + // OpenCode <= 1.3.3 calls every export as a plugin, so its file must export + // the V1 function and nothing else. + const legacy = join( + ROOT, + 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js' + ); + const legacyRaw = existsSync(legacy) ? readFileSync(legacy, 'utf8') : ''; + check( + 'opencode legacy: exports only the V1 plugin function', + legacyRaw.includes('export const TokenOptimizerPlugin') && + (legacyRaw.match(/^export\b/gm) || []).length === 1 + ); +} + const failed = results.filter((r) => !r.pass); console.log( `\n${results.length - failed.length}/${results.length} checks passed` diff --git a/tests/integration/client-hooks.test.ts b/tests/integration/client-hooks.test.ts index ce5496b8c..46781907a 100644 --- a/tests/integration/client-hooks.test.ts +++ b/tests/integration/client-hooks.test.ts @@ -45,6 +45,10 @@ const openCodePlugin = join( repoRoot, 'integrations/opencode/.opencode/plugins/token-optimizer.js' ); +const openCodeLegacyPlugin = join( + repoRoot, + 'integrations/opencode/legacy/.opencode/plugins/token-optimizer.js' +); const kiloPlugin = join( repoRoot, 'integrations/kilo/.kilo/plugin/token-optimizer.js' @@ -420,8 +424,197 @@ describe('native CLI hook integrations', () => { expect(compactionOutput.context.join('\n')).toContain('smart_read'); }); + // Each shipped file against a replica of the loader it is for, taken from + // OpenCode's packages/opencode/src/plugin/index.ts (v1.3.3, v1.3.4) and + // packages/core/src/plugin/module.ts (2.x). No single file can pass all three: + // up to 1.3.3 every export is called as a plugin, while 2.x accepts only an + // object default. That is why the legacy file exists, and why it is generated + // from the current one rather than maintained beside it. + it('ships an OpenCode plugin file that each loader generation accepts', async () => { + type Hooks = Record; + const load = async (file: string) => + (await import( + `${pathToFileURL(file).href}?loader=${Date.now()}` + )) as Record; + const input = { directory: fixtureDir }; + + // <= 1.3.3: Object.entries(mod), deduplicated by reference, each one called. + const upTo133 = async (mod: Record) => { + const seen = new Set(); + const hooks: Hooks[] = []; + for (const fn of Object.values(mod)) { + if (seen.has(fn)) continue; + seen.add(fn); + hooks.push(await (fn as (i: unknown) => Promise)(input)); + } + return hooks; + }; + // 1.3.4 to 1.x: a default object with a server() wins over the named exports. + const from134 = async (mod: Record) => { + const value = mod.default as + | { id?: unknown; server?: unknown } + | undefined; + expect(value !== null && typeof value === 'object').toBe(true); + expect(typeof value?.id).toBe('string'); + expect(typeof value?.server).toBe('function'); + return (value?.server as (i: unknown) => Promise)(input); + }; + // 2.x: the default must be an object with a string id and setup(). + const v2Accepts = (mod: Record) => { + const value = mod.default as + | { id?: unknown; setup?: unknown } + | undefined; + return ( + value !== null && + typeof value === 'object' && + typeof value.id === 'string' && + typeof value.setup === 'function' + ); + }; + + const legacy = await load(openCodeLegacyPlugin); + expect(Object.keys(legacy)).toEqual(['TokenOptimizerPlugin']); + const legacyHooks = await upTo133(legacy); + expect(legacyHooks).toHaveLength(1); + expect(Object.keys(legacyHooks[0]).sort()).toEqual([ + 'experimental.chat.system.transform', + 'experimental.session.compacting', + 'tool.execute.after', + 'tool.execute.before', + ]); + + const current = await load(openCodePlugin); + await expect(upTo133(current)).rejects.toThrow(TypeError); + expect(Object.keys(await from134(current)).sort()).toEqual( + Object.keys(legacyHooks[0]).sort() + ); + expect(v2Accepts(current)).toBe(true); + expect(v2Accepts(legacy)).toBe(false); + }); + + // OpenCode 2.x dropped the V1 hook-name map: a plugin must default-export an + // object with an id and a setup(ctx) that registers through session.hook / + // tool.hook, or it fails to load with "Plugin must export a default + // definition". This exercises that registration path with a fake V2 context + // so the shipped file is known to serve both plugin APIs. + it('preserves enforcement and guidance through the OpenCode V2 default export', async () => { + const pluginModule = (await import( + `${pathToFileURL(openCodePlugin).href}?v2=${Date.now()}` + )) as { + default: { + id: string; + server: unknown; + setup: (ctx: unknown) => Promise; + }; + }; + expect(pluginModule.default.id).toBe('token-optimizer'); + + const registered: Record Promise> = {}; + const context = { + location: { directory: fixtureDir }, + session: { + hook: async (name: string, callback: (event: unknown) => void) => { + registered[`session:${name}`] = callback; + }, + }, + tool: { + hook: async (name: string, callback: (event: unknown) => void) => { + registered[`tool:${name}`] = callback; + }, + }, + }; + await pluginModule.default.setup(context); + + expect(Object.keys(registered).sort()).toEqual([ + 'session:compaction', + 'session:context', + 'tool:execute.after', + 'tool:execute.before', + ]); + + const previousMode = process.env.TOKEN_OPTIMIZER_MODE; + const previousCapabilities = process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + delete process.env.TOKEN_OPTIMIZER_MODE; + delete process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + + try { + // Mirrors the V1 contract test above: unset means assist (no refusal), + // so the deny case must ask for `enforce` explicitly -- otherwise this + // proves only that nothing happened. + process.env.TOKEN_OPTIMIZER_MODE = 'enforce'; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-enforce', + id: 'call-1', + input: { filePath: largeFile }, + }) + ).rejects.toThrow('smart_read'); + + // The new default is pinned here too: unset must mean assist, so the + // large read goes through untouched. + delete process.env.TOKEN_OPTIMIZER_MODE; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-default', + id: 'call-2', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + process.env.TOKEN_OPTIMIZER_MODE = 'advise'; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-advise', + id: 'call-3', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + // Fail-open on an empty inventory, checked under enforce for the same + // reason as above. + process.env.TOKEN_OPTIMIZER_MODE = 'enforce'; + process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES = ''; + await expect( + registered['tool:execute.before']({ + tool: 'read', + sessionID: 'v2-empty', + id: 'call-4', + input: { filePath: largeFile }, + }) + ).resolves.toBeUndefined(); + + delete process.env.TOKEN_OPTIMIZER_MODE; + const compaction = { system: [] as { type: string; text: string }[] }; + await registered['session:compaction'](compaction); + expect(compaction.system.map((part) => part.text).join('\n')).toContain( + 'smart_read' + ); + + const contextRequest = { system: [] as { type: string; text: string }[] }; + await registered['session:context'](contextRequest); + expect(contextRequest.system.every((part) => part.type === 'text')).toBe( + true + ); + } finally { + if (previousMode === undefined) { + delete process.env.TOKEN_OPTIMIZER_MODE; + } else { + process.env.TOKEN_OPTIMIZER_MODE = previousMode; + } + if (previousCapabilities === undefined) { + delete process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES; + } else { + process.env.TOKEN_OPTIMIZER_MCP_CAPABILITIES = previousCapabilities; + } + } + }); + it.each([ ['OpenCode', openCodePlugin], + ['OpenCode legacy', openCodeLegacyPlugin], ['Kilo', kiloPlugin], ])( '%s executes its staged shared hook, refuses when enforcing, and allows by default', diff --git a/tests/unit/generators-are-eol-insensitive.test.ts b/tests/unit/generators-are-eol-insensitive.test.ts index fe9360c23..2814df290 100644 --- a/tests/unit/generators-are-eol-insensitive.test.ts +++ b/tests/unit/generators-are-eol-insensitive.test.ts @@ -166,6 +166,7 @@ describe('every generator', () => { const GENERATORS = [ 'scripts/sync-hook-core.mjs', 'scripts/generate-client-entries.mjs', + 'scripts/generate-opencode-legacy-plugin.mjs', 'scripts/generate-client-configs.mjs', ];