diff --git a/.opencode/plugins/ecc-hooks.ts b/.opencode/plugins/ecc-hooks.ts index 4aabde612..bf06c03f8 100644 --- a/.opencode/plugins/ecc-hooks.ts +++ b/.opencode/plugins/ecc-hooks.ts @@ -481,7 +481,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ * Triggers: Before shell command execution * Action: Sets PROJECT_ROOT, PACKAGE_MANAGER, DETECTED_LANGUAGES, ECC_VERSION */ - "shell.env": async () => { + "shell.env": async (_input: { cwd: string }, output: { env: Record }) => { const env: Record = { ECC_VERSION: getECCVersion(), ECC_PLUGIN: "true", @@ -523,7 +523,8 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ env.PRIMARY_LANGUAGE = detected[0] } - return env + // OpenCode reads the supplied output object and ignores callback return values. + output.env = { ...output.env, ...env } }, /** @@ -531,9 +532,12 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ * OpenCode-specific: Control context compaction behavior * * Triggers: Before context compaction - * Action: Push ECC context block and custom compaction prompt + * Action: Push ECC context block and compaction guidance */ - "experimental.session.compacting": async () => { + "experimental.session.compacting": async ( + _input: { sessionID: string }, + output: { context: string[]; prompt?: string } + ) => { const contextBlock = [ "# ECC Context (preserve across compaction)", "", @@ -558,9 +562,16 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({ contextBlock.push("") } - return { - context: contextBlock.join("\n"), - compaction_prompt: "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.", + const eccContext = [ + contextBlock.join("\n"), + "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.", + ] + + // OpenCode requires output assignment and skips context when a prompt is set. + if (output.prompt !== undefined) { + output.prompt = [output.prompt, ...eccContext].join("\n\n") + } else { + output.context = [...output.context, ...eccContext] } }, diff --git a/tests/opencode-plugin-hooks.test.js b/tests/opencode-plugin-hooks.test.js index 0d261ef5c..a6c3ed2ae 100644 --- a/tests/opencode-plugin-hooks.test.js +++ b/tests/opencode-plugin-hooks.test.js @@ -179,9 +179,14 @@ async function main() { const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const env = await hooks["shell.env"]() + const existingEnv = Object.freeze({ EXISTING_ENV: "preserved" }) + const output = { env: existingEnv } + await hooks["shell.env"]({ cwd: projectDir }, output) + const { env } = output assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.strictEqual(env.EXISTING_ENV, "preserved") + assert.notStrictEqual(env, existingEnv) assert.strictEqual(env.PROJECT_ROOT, projectDir) assert.strictEqual(env.PACKAGE_MANAGER, "pnpm") assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python") @@ -243,9 +248,12 @@ async function main() { const $ = createFailingShell() const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) - const env = await hooks["shell.env"]() + const output = { env: {} } + await hooks["shell.env"]({ cwd: projectDir }, output) + const { env } = output assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`) + assert.strictEqual(env.PROJECT_ROOT, projectDir) assert.ok(!("PACKAGE_MANAGER" in env), "Lockfile directory should not set PACKAGE_MANAGER") assert.ok(!("DETECTED_LANGUAGES" in env), "Marker directory should not set DETECTED_LANGUAGES") assert.ok(!("PRIMARY_LANGUAGE" in env), "Marker directory should not set PRIMARY_LANGUAGE") @@ -254,6 +262,47 @@ async function main() { } }, ], + [ + "compacting appends ECC context without replacing the host compaction prompt", + async () => withTempProject([], async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + const existingContext = Object.freeze(["Existing plugin context"]) + const output = { context: existingContext } + + await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output) + + assert.strictEqual(output.context[0], "Existing plugin context") + assert.notStrictEqual(output.context, existingContext) + const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n") + assert.ok(prompt.includes("Default compaction prompt")) + assert.ok(prompt.includes("# ECC Context")) + assert.ok(prompt.includes("Current task status and progress")) + assert.deepStrictEqual($.calls, []) + }), + ], + [ + "compacting appends ECC guidance to custom prompts, including an empty prompt", + async () => withTempProject([], async (projectDir) => { + const client = createClient() + const $ = createFailingShell() + const hooks = await ECCHooksPlugin({ client, $, directory: projectDir }) + + for (const customPrompt of ["Another plugin's custom prompt", ""]) { + const existingContext = Object.freeze(["Existing plugin context"]) + const output = { context: existingContext, prompt: customPrompt } + await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output) + + const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n") + assert.ok(prompt.startsWith(`${customPrompt}\n\n`)) + assert.ok(prompt.includes("# ECC Context")) + assert.ok(prompt.includes("Current task status and progress")) + assert.strictEqual(output.context, existingContext) + } + assert.deepStrictEqual($.calls, []) + }), + ], [ "permission.ask handles read-only tools correctly", async () => withTempProject(