diff --git a/src/hooks/cmux-notification-adapter.test.ts b/src/hooks/cmux-notification-adapter.test.ts index f260cfb11..1e5308ca6 100644 --- a/src/hooks/cmux-notification-adapter.test.ts +++ b/src/hooks/cmux-notification-adapter.test.ts @@ -1,4 +1,7 @@ import { describe, expect, test } from "bun:test" +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" import { createCmuxNotificationAdapter, type CmuxNotifyCommandResult, @@ -139,4 +142,48 @@ describe("cmux notification adapter", () => { expect(secondDelivered).toBe(false) expect(callCount).toBe(1) }) + + test("returns promptly on timeout when cmux process ignores TERM", async () => { + if (process.platform === "win32") { + return + } + + const tempDirectory = mkdtempSync(join(tmpdir(), "cmux-notify-timeout-")) + const fakeCmuxPath = join(tempDirectory, "cmux") + const slowCmuxScript = `#!/bin/sh +if [ "$1" = "notify" ]; then + trap '' TERM + /bin/sleep 1 + exit 0 +fi + +exit 1 +` + + writeFileSync(fakeCmuxPath, slowCmuxScript) + chmodSync(fakeCmuxPath, 0o755) + + const runtime = createResolvedMultiplexer() + runtime.cmux.path = fakeCmuxPath + + try { + const adapter = createCmuxNotificationAdapter({ + runtime, + environment: { + PATH: tempDirectory, + }, + timeoutMs: 40, + }) + + const startedAt = Date.now() + const delivered = await adapter.send("OpenCode", "Task complete") + const elapsedMs = Date.now() - startedAt + + expect(delivered).toBe(false) + expect(adapter.hasDowngraded()).toBe(true) + expect(elapsedMs).toBeLessThan(500) + } finally { + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) }) diff --git a/src/hooks/cmux-notification-adapter.ts b/src/hooks/cmux-notification-adapter.ts index acbd3b4f4..8975977b7 100644 --- a/src/hooks/cmux-notification-adapter.ts +++ b/src/hooks/cmux-notification-adapter.ts @@ -83,7 +83,20 @@ async function runCmuxNotifyCommand(input: { clearTimeout(timeoutHandle) } - const exitCode = timedOut ? null : await proc.exited.catch(() => null) + if (timedOut) { + // Do not await stdout/stderr after timeout: a process that ignores TERM + // may keep pipes open and block fallback completion. + void proc.exited.catch(() => {}) + + return { + exitCode: null, + stdout: "", + stderr: "", + timedOut: true, + } + } + + const exitCode = await proc.exited.catch(() => null) const stdout = await new Response(proc.stdout).text().catch(() => "") const stderr = await new Response(proc.stderr).text().catch(() => "") diff --git a/src/tools/interactive-bash/tmux-path-resolver.test.ts b/src/tools/interactive-bash/tmux-path-resolver.test.ts index b3121be46..46e0122b3 100644 --- a/src/tools/interactive-bash/tmux-path-resolver.test.ts +++ b/src/tools/interactive-bash/tmux-path-resolver.test.ts @@ -83,6 +83,10 @@ describe("tmux-path-resolver probe environment", () => { }) test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => { + if (process.platform === "win32") { + return + } + const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-")) const fakeCmuxPath = join(tempDirectory, "cmux") const slowCmuxScript = `#!/bin/sh diff --git a/src/tools/interactive-bash/tools.test.ts b/src/tools/interactive-bash/tools.test.ts new file mode 100644 index 000000000..9bac6a39a --- /dev/null +++ b/src/tools/interactive-bash/tools.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { + resetResolvedMultiplexerRuntimeForTesting, + setResolvedMultiplexerRuntime, + type ResolvedMultiplexer, +} from "../../shared/tmux" +import { createInteractiveBashTool, interactive_bash } from "./tools" + +const mockToolContext = { + sessionID: "test-session", + messageID: "msg-1", + agent: "test-agent", + abort: new AbortController().signal, +} + +function createTmuxEnabledRuntime(): ResolvedMultiplexer { + return { + platform: process.platform, + mode: "tmux-only", + paneBackend: "tmux", + notificationBackend: "desktop", + tmux: { + path: "/usr/bin/tmux", + reachable: true, + insideEnvironment: true, + paneId: "%1", + explicitDisable: false, + }, + cmux: { + path: null, + reachable: false, + notifyCapable: false, + socketPath: undefined, + endpointType: "missing", + workspaceId: undefined, + surfaceId: undefined, + hintStrength: "none", + explicitDisable: false, + }, + } +} + +describe("interactive_bash runtime resolution", () => { + afterEach(() => { + resetResolvedMultiplexerRuntimeForTesting() + }) + + test("createInteractiveBashTool without runtime resolves current runtime on execute", async () => { + resetResolvedMultiplexerRuntimeForTesting() + const tool = createInteractiveBashTool() + setResolvedMultiplexerRuntime(createTmuxEnabledRuntime()) + + const result = await tool.execute({ tmux_command: "capture-pane -p" }, mockToolContext) + + expect(result).not.toContain("pane control is unavailable") + }) + + test("interactive_bash singleton resolves current runtime on execute", async () => { + resetResolvedMultiplexerRuntimeForTesting() + setResolvedMultiplexerRuntime(createTmuxEnabledRuntime()) + + const result = await interactive_bash.execute({ tmux_command: "capture-pane -p" }, mockToolContext) + + expect(result).not.toContain("pane control is unavailable") + }) +}) diff --git a/src/tools/interactive-bash/tools.ts b/src/tools/interactive-bash/tools.ts index 6421e018c..49198cb20 100644 --- a/src/tools/interactive-bash/tools.ts +++ b/src/tools/interactive-bash/tools.ts @@ -54,9 +54,7 @@ export function tokenizeCommand(cmd: string): string[] { } export function createInteractiveBashTool( - runtime: ResolvedMultiplexer = - getResolvedMultiplexerRuntime() - ?? createDisabledMultiplexerRuntime(), + runtime?: ResolvedMultiplexer, ): ToolDefinition { return tool({ description: INTERACTIVE_BASH_DESCRIPTION, @@ -65,8 +63,13 @@ export function createInteractiveBashTool( }, execute: async (args) => { try { - if (runtime.paneBackend !== "tmux") { - return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${runtime.mode}' runtime.` + const resolvedRuntime = + runtime + ?? getResolvedMultiplexerRuntime() + ?? createDisabledMultiplexerRuntime() + + if (resolvedRuntime.paneBackend !== "tmux") { + return `Error: interactive_bash is TMUX-only and pane control is unavailable in '${resolvedRuntime.mode}' runtime.` } const tmuxPath = await getTmuxPath()