fix(non-interactive-env): use export for env vars to apply to all chained commands

Previous `VAR=val cmd` format only applied to first command in chains.
New `export VAR=val; cmd` format ensures variables persist for all commands.

Also increased test timeouts for todo-continuation-enforcer stability.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
YeonGyu-Kim
2026-01-03 15:36:01 +09:00
parent 03c51c9321
commit f61e1a5f2b
3 changed files with 37 additions and 16 deletions

View File

@@ -5,7 +5,7 @@ describe("non-interactive-env hook", () => {
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0] const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
describe("git command modification", () => { 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 hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = { const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git commit -m 'test'" }, args: { command: "git commit -m 'test'" },
@@ -17,10 +17,27 @@ describe("non-interactive-env hook", () => {
) )
const cmd = output.args.command as string const cmd = output.args.command as string
expect(cmd).toStartWith("export ")
expect(cmd).toContain("GIT_EDITOR=:") expect(cmd).toContain("GIT_EDITOR=:")
expect(cmd).toContain("EDITOR=:") expect(cmd).toContain("EDITOR=:")
expect(cmd).toContain("PAGER=cat") 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<string, unknown>; 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 () => { test("#given non-git bash command #when hook executes #then command unchanged", async () => {

View File

@@ -34,16 +34,18 @@ function shellEscape(value: string): string {
} }
/** /**
* Build env prefix string with line continuation for readability: * Build export statement for environment variables.
* VAR1=val1 \ * Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
* VAR2=val2 \ * 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. * OpenCode's bash tool ignores args.env, so we must prepend to command.
*/ */
function buildEnvPrefix(env: Record<string, string>): string { function buildEnvPrefix(env: Record<string, string>): string {
return Object.entries(env) const exports = Object.entries(env)
.map(([key, value]) => `${key}=${shellEscape(value)}`) .map(([key, value]) => `${key}=${shellEscape(value)}`)
.join(" \\\n") .join(" ")
return `export ${exports};`
} }
export function createNonInteractiveEnvHook(_ctx: PluginInput) { export function createNonInteractiveEnvHook(_ctx: PluginInput) {
@@ -73,9 +75,11 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
} }
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(), // 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) 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`, { log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
sessionID: input.sessionID, sessionID: input.sessionID,

View File

@@ -415,7 +415,7 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 2500)) await new Promise(r => setTimeout(r, 3500))
// #then - first injection happened // #then - first injection happened
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
@@ -424,11 +424,11 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({ await hook.handler({
event: { type: "session.idle", properties: { sessionID } }, 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) // #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2) expect(promptCalls.length).toBe(2)
}, { timeout: 10000 }) }, { timeout: 15000 })
// ============================================================ // ============================================================
// ABORT "IMMEDIATELY BEFORE" DETECTION TESTS // ABORT "IMMEDIATELY BEFORE" DETECTION TESTS
@@ -589,7 +589,7 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } }, event: { type: "session.idle", properties: { sessionID } },
}) })
await new Promise(r => setTimeout(r, 3000)) await new Promise(r => setTimeout(r, 3500))
expect(promptCalls).toHaveLength(0) expect(promptCalls).toHaveLength(0)
// #when - second idle event occurs (abort is no longer "immediately before") // #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 } }, 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) // #then - continuation injected on second idle (abort state was consumed)
expect(promptCalls.length).toBe(1) expect(promptCalls.length).toBe(1)
}, { timeout: 10000 }) }, { timeout: 15000 })
test("should handle multiple abort errors correctly - only last one matters", async () => { test("should handle multiple abort errors correctly - only last one matters", async () => {
// #given - session with incomplete todos // #given - session with incomplete todos