diff --git a/src/hooks/non-interactive-env/index.test.ts b/src/hooks/non-interactive-env/index.test.ts index 627c0ee..7b4502a 100644 --- a/src/hooks/non-interactive-env/index.test.ts +++ b/src/hooks/non-interactive-env/index.test.ts @@ -5,7 +5,7 @@ describe("non-interactive-env hook", () => { const mockCtx = {} as Parameters[0] describe("git command modification", () => { - test("#given git command #when hook executes #then prepends env vars", async () => { + test("#given git command #when hook executes #then prepends export statement", async () => { const hook = createNonInteractiveEnvHook(mockCtx) const output: { args: Record; message?: string } = { args: { command: "git commit -m 'test'" }, @@ -17,10 +17,27 @@ describe("non-interactive-env hook", () => { ) const cmd = output.args.command as string + expect(cmd).toStartWith("export ") expect(cmd).toContain("GIT_EDITOR=:") expect(cmd).toContain("EDITOR=:") expect(cmd).toContain("PAGER=cat") - expect(cmd).toEndWith(" git commit -m 'test'") + expect(cmd).toContain("; git commit -m 'test'") + }) + + test("#given chained git commands #when hook executes #then export applies to all", async () => { + const hook = createNonInteractiveEnvHook(mockCtx) + const output: { args: Record; message?: string } = { + args: { command: "git add file && git rebase --continue" }, + } + + await hook["tool.execute.before"]( + { tool: "bash", sessionID: "test", callID: "1" }, + output + ) + + const cmd = output.args.command as string + expect(cmd).toStartWith("export ") + expect(cmd).toContain("; git add file && git rebase --continue") }) test("#given non-git bash command #when hook executes #then command unchanged", async () => { diff --git a/src/hooks/non-interactive-env/index.ts b/src/hooks/non-interactive-env/index.ts index 0d21044..a7f51d6 100644 --- a/src/hooks/non-interactive-env/index.ts +++ b/src/hooks/non-interactive-env/index.ts @@ -34,16 +34,18 @@ function shellEscape(value: string): string { } /** - * Build env prefix string with line continuation for readability: - * VAR1=val1 \ - * VAR2=val2 \ - * ... + * Build export statement for environment variables. + * Uses `export VAR1=val1 VAR2=val2;` format to ensure variables + * apply to ALL commands in a chain (e.g., `cmd1 && cmd2`). + * + * Previous approach used VAR=value prefix which only applies to the first command. * OpenCode's bash tool ignores args.env, so we must prepend to command. */ function buildEnvPrefix(env: Record): string { - return Object.entries(env) + const exports = Object.entries(env) .map(([key, value]) => `${key}=${shellEscape(value)}`) - .join(" \\\n") + .join(" ") + return `export ${exports};` } export function createNonInteractiveEnvHook(_ctx: PluginInput) { @@ -73,9 +75,11 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) { } // OpenCode's bash tool uses hardcoded `...process.env` in spawn(), - // ignoring any args.env we might set. Prepend to command instead. + // ignoring any args.env we might set. Prepend export statement to command. + // Uses `export VAR=val;` format to ensure variables apply to ALL commands + // in a chain (e.g., `git add file && git rebase --continue`). const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV) - output.args.command = `${envPrefix} \\\n${command}` + output.args.command = `${envPrefix} ${command}` log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { sessionID: input.sessionID, diff --git a/src/hooks/todo-continuation-enforcer.test.ts b/src/hooks/todo-continuation-enforcer.test.ts index 8594274..00709a8 100644 --- a/src/hooks/todo-continuation-enforcer.test.ts +++ b/src/hooks/todo-continuation-enforcer.test.ts @@ -415,7 +415,7 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } }, }) - await new Promise(r => setTimeout(r, 2500)) + await new Promise(r => setTimeout(r, 3500)) // #then - first injection happened expect(promptCalls.length).toBe(1) @@ -424,11 +424,11 @@ describe("todo-continuation-enforcer", () => { await hook.handler({ event: { type: "session.idle", properties: { sessionID } }, }) - await new Promise(r => setTimeout(r, 2500)) + await new Promise(r => setTimeout(r, 3500)) // #then - second injection also happened (no throttle blocking) expect(promptCalls.length).toBe(2) - }, { timeout: 10000 }) + }, { timeout: 15000 }) // ============================================================ // ABORT "IMMEDIATELY BEFORE" DETECTION TESTS @@ -589,7 +589,7 @@ describe("todo-continuation-enforcer", () => { event: { type: "session.idle", properties: { sessionID } }, }) - await new Promise(r => setTimeout(r, 3000)) + await new Promise(r => setTimeout(r, 3500)) expect(promptCalls).toHaveLength(0) // #when - second idle event occurs (abort is no longer "immediately before") @@ -597,11 +597,11 @@ describe("todo-continuation-enforcer", () => { event: { type: "session.idle", properties: { sessionID } }, }) - await new Promise(r => setTimeout(r, 2500)) + await new Promise(r => setTimeout(r, 3500)) // #then - continuation injected on second idle (abort state was consumed) expect(promptCalls.length).toBe(1) - }, { timeout: 10000 }) + }, { timeout: 15000 }) test("should handle multiple abort errors correctly - only last one matters", async () => { // #given - session with incomplete todos