From f93e15b2a9c1250e6a6b2d3e858615d3c0b90de0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:05 +0900 Subject: [PATCH] feat(tmux): add pane-replace utility with tests --- src/shared/shell-env.ts | 4 + .../tmux/tmux-utils/pane-replace.test.ts | 153 ++++++++++++++++++ src/shared/tmux/tmux-utils/pane-replace.ts | 45 ++---- 3 files changed, 173 insertions(+), 29 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/pane-replace.test.ts diff --git a/src/shared/shell-env.ts b/src/shared/shell-env.ts index 28041298a..fdbdc2aef 100644 --- a/src/shared/shell-env.ts +++ b/src/shared/shell-env.ts @@ -173,3 +173,7 @@ export function shellEscapeForDoubleQuotedCommand(value: string): string { .replace(/\(/g, "\\(") // escape parentheses .replace(/\)/g, "\\)") // escape parentheses } + +export function shellSingleQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'` +} diff --git a/src/shared/tmux/tmux-utils/pane-replace.test.ts b/src/shared/tmux/tmux-utils/pane-replace.test.ts new file mode 100644 index 000000000..0f7f73eb8 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-replace.test.ts @@ -0,0 +1,153 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxConfig } from "../../../config/schema" +import type { TmuxCommandResult } from "../runner" + +const paneReplaceSpecifier = import.meta.resolve("./pane-replace") +const environmentSpecifier = import.meta.resolve("./environment") +const loggerSpecifier = import.meta.resolve("../../logger") +const runnerSpecifier = import.meta.resolve("../runner") +const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver") + +const enabledTmuxConfig = { + enabled: true, + layout: "main-vertical", + main_pane_size: 60, + main_pane_min_width: 120, + agent_pane_min_width: 40, + isolation: "inline", +} satisfies TmuxConfig + +const runTmuxCommandMock = mock(async (): Promise => ({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, +})) +const isInsideTmuxMock = mock((): boolean => true) +const getTmuxPathMock = mock(async (): Promise => "sh") +const logMock = mock(() => undefined) + +function toStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + throw new Error("Expected array value") + } + + const items: string[] = [] + for (const item of value) { + items.push(String(item)) + } + return items +} + +function getRunTmuxCommandCall(index: number): [string, string[]] { + const call = Reflect.get(runTmuxCommandMock.mock.calls, index) + const command = Reflect.get(call, 0) + const args = Reflect.get(call, 1) + if (!Array.isArray(call) || typeof command !== "string" || !Array.isArray(args)) { + throw new Error(`Expected tmux runner call at index ${index}`) + } + + return [command, toStringArray(args)] +} + +function getRespawnCommand(): string { + const respawnCall = getRunTmuxCommandCall(1) + const respawnCommand = respawnCall[1][4] + if (respawnCommand === undefined) { + throw new Error("Expected respawn-pane command") + } + + return respawnCommand +} + +async function loadReplaceTmuxPane(): Promise { + const module = await import(`${paneReplaceSpecifier}?test=${crypto.randomUUID()}`) + return module.replaceTmuxPane +} + +function registerModuleMocks(): void { + mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) + mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +} + +describe("replaceTmuxPane runner integration", () => { + beforeEach(() => { + mock.restore() + registerModuleMocks() + runTmuxCommandMock.mockClear() + isInsideTmuxMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + const tmuxCommandResults: TmuxCommandResult[] = [ + { success: true, output: "", stdout: "", stderr: "", exitCode: 0 }, + { success: true, output: "", stdout: "", stderr: "", exitCode: 0 }, + { success: true, output: "", stdout: "", stderr: "", exitCode: 0 }, + ] + runTmuxCommandMock.mockImplementation(async (): Promise => { + const nextResult = tmuxCommandResults.shift() + if (!nextResult) { + throw new Error("No more tmux command results configured") + } + return nextResult + }) + isInsideTmuxMock.mockReturnValue(true) + getTmuxPathMock.mockResolvedValue("sh") + }) + + it("#given existing pane #when replaceTmuxPane called #then delegates send-keys, respawn-pane, and select-pane to shared runner", async () => { + // given + const replaceTmuxPane = await loadReplaceTmuxPane() + const directory = "/tmp/omo-project/(replace)" + + // when + const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory) + + // then + const sendKeysCall = getRunTmuxCommandCall(0) + const respawnCall = getRunTmuxCommandCall(1) + const selectPaneCall = getRunTmuxCommandCall(2) + expect(result).toEqual({ success: true, paneId: "%42" }) + expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"]) + expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"]) + expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) + expect(getRespawnCommand()).toContain(` --dir '${directory}'`) + }) + + it("#given directory with spaces #when replaceTmuxPane called #then wraps --dir value in single quotes", async () => { + // given + const replaceTmuxPane = await loadReplaceTmuxPane() + + // when + await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here") + + // then + expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'") + }) + + it("#given empty directory #when replaceTmuxPane called #then falls back to process cwd", async () => { + // given + const replaceTmuxPane = await loadReplaceTmuxPane() + + // when + await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "") + + // then + expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`) + }) + + it("#given directory with single quotes #when replaceTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => { + // given + const replaceTmuxPane = await loadReplaceTmuxPane() + + // when + await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote") + + // then + expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'") + }) +}) diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts index 12ee33116..dab8d1702 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -1,9 +1,8 @@ -import { spawn } from "../../bun-spawn-shim" import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" import { isInsideTmux } from "./environment" -import { shellEscapeForDoubleQuotedCommand } from "../../shell-env" +import { shellSingleQuote } from "../../shell-env" export async function replaceTmuxPane( paneId: string, @@ -11,8 +10,12 @@ export async function replaceTmuxPane( description: string, config: TmuxConfig, serverUrl: string, + directory: string, ): Promise { - const { log } = await import("../../logger") + const [{ log }, { runTmuxCommand }] = await Promise.all([ + import("../../logger"), + import("../runner"), + ]) log("[replaceTmuxPane] called", { paneId, sessionId, description }) @@ -29,42 +32,26 @@ export async function replaceTmuxPane( } log("[replaceTmuxPane] sending Ctrl+C for graceful shutdown", { paneId }) - const ctrlCProc = spawn([tmux, "send-keys", "-t", paneId, "C-c"], { - stdout: "pipe", - stderr: "pipe", - }) - await ctrlCProc.exited + await runTmuxCommand(tmux, ["send-keys", "-t", paneId, "C-c"]) - const shell = process.env.SHELL || "/bin/sh" - const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl) - const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"` + const effectiveDirectory = directory || process.cwd() + const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` - const proc = spawn([tmux, "respawn-pane", "-k", "-t", paneId, opencodeCmd], { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited + const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd]) - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - log("[replaceTmuxPane] FAILED", { paneId, exitCode, stderr: stderr.trim() }) + if (result.exitCode !== 0) { + log("[replaceTmuxPane] FAILED", { paneId, exitCode: result.exitCode, stderr: result.stderr.trim() }) return { success: false } } const title = `omo-subagent-${description.slice(0, 20)}` - const titleProc = spawn([tmux, "select-pane", "-t", paneId, "-T", title], { - stdout: "ignore", - stderr: "pipe", - }) - const stderrPromise = new Response(titleProc.stderr).text().catch(() => "") - const titleExitCode = await titleProc.exited - if (titleExitCode !== 0) { - const titleStderr = await stderrPromise + const titleResult = await runTmuxCommand(tmux, ["select-pane", "-t", paneId, "-T", title]) + if (titleResult.exitCode !== 0) { log("[replaceTmuxPane] WARNING: failed to set pane title", { paneId, title, - exitCode: titleExitCode, - stderr: titleStderr.trim(), + exitCode: titleResult.exitCode, + stderr: titleResult.stderr.trim(), }) }