From 56250a468cb94a2c8f49ee95996e8a5a277a85c8 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:07 +0900 Subject: [PATCH] feat(tmux): add pane-spawn utility with runner tests --- .../tmux/tmux-utils/pane-spawn-runner.test.ts | 155 ++++++++++++++++++ src/shared/tmux/tmux-utils/pane-spawn.ts | 36 ++-- 2 files changed, 170 insertions(+), 21 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts diff --git a/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts new file mode 100644 index 000000000..f4b5210d5 --- /dev/null +++ b/src/shared/tmux/tmux-utils/pane-spawn-runner.test.ts @@ -0,0 +1,155 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxConfig } from "../../../config/schema" +import type { TmuxCommandResult } from "../runner" + +const paneSpawnSpecifier = import.meta.resolve("./pane-spawn") +const environmentSpecifier = import.meta.resolve("./environment") +const loggerSpecifier = import.meta.resolve("../../logger") +const runnerSpecifier = import.meta.resolve("../runner") +const serverHealthSpecifier = import.meta.resolve("./server-health") +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: "%42", + stdout: "%42", + stderr: "", + exitCode: 0, +})) +const isInsideTmuxMock = mock((): boolean => true) +const isServerRunningMock = mock(async (): Promise => 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 getSplitWindowCommand(): string { + const firstCall = getRunTmuxCommandCall(0) + const splitCommand = firstCall[1][8] + if (splitCommand === undefined) { + throw new Error("Expected split-window command") + } + + return splitCommand +} + +async function loadSpawnTmuxPane(): Promise { + const module = await import(`${paneSpawnSpecifier}?test=${crypto.randomUUID()}`) + return module.spawnTmuxPane +} + +function registerModuleMocks(): void { + mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) + mock.module(loggerSpecifier, () => ({ log: logMock })) + mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) + mock.module(serverHealthSpecifier, () => ({ isServerRunning: isServerRunningMock })) + mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +} + +describe("spawnTmuxPane runner integration", () => { + beforeEach(() => { + mock.restore() + registerModuleMocks() + runTmuxCommandMock.mockClear() + isInsideTmuxMock.mockClear() + isServerRunningMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + const tmuxCommandResults: TmuxCommandResult[] = [ + { success: true, output: "%42", stdout: "%42", 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) + isServerRunningMock.mockResolvedValue(true) + getTmuxPathMock.mockResolvedValue("sh") + }) + + it("#given healthy tmux environment #when spawnTmuxPane called #then delegates split-window and select-pane to shared runner", async () => { + // given + const spawnTmuxPane = await loadSpawnTmuxPane() + const directory = "/tmp/omo-project/(pane)" + + // when + const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0") + + // then + const firstCall = getRunTmuxCommandCall(0) + const secondCall = getRunTmuxCommandCall(1) + expect(result).toEqual({ success: true, paneId: "%42" }) + expect(firstCall[1].slice(0, 8)).toEqual(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"]) + expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) + expect(getSplitWindowCommand()).toContain(` --dir '${directory}'`) + }) + + it("#given directory with spaces #when spawnTmuxPane called #then wraps --dir value in single quotes", async () => { + // given + const spawnTmuxPane = await loadSpawnTmuxPane() + + // when + await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0") + + // then + expect(getSplitWindowCommand()).toContain("--dir '/path with spaces/here'") + }) + + it("#given empty directory #when spawnTmuxPane called #then falls back to process cwd", async () => { + // given + const spawnTmuxPane = await loadSpawnTmuxPane() + + // when + await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0") + + // then + expect(getSplitWindowCommand()).toContain(`--dir '${process.cwd()}'`) + }) + + it("#given directory with single quotes #when spawnTmuxPane called #then escapes the value with POSIX-safe single quoting", async () => { + // given + const spawnTmuxPane = await loadSpawnTmuxPane() + + // when + await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0") + + // then + expect(getSplitWindowCommand()).toContain("--dir '/path/with'\\''quote'") + }) +}) diff --git a/src/shared/tmux/tmux-utils/pane-spawn.ts b/src/shared/tmux/tmux-utils/pane-spawn.ts index 779da3031..a4b00d2ff 100644 --- a/src/shared/tmux/tmux-utils/pane-spawn.ts +++ b/src/shared/tmux/tmux-utils/pane-spawn.ts @@ -1,21 +1,24 @@ -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 type { SplitDirection } from "./environment" import { isInsideTmux } from "./environment" import { isServerRunning } from "./server-health" -import { shellEscapeForDoubleQuotedCommand } from "../../shell-env" +import { shellSingleQuote } from "../../shell-env" export async function spawnTmuxPane( sessionId: string, description: string, config: TmuxConfig, serverUrl: string, + directory: string, targetPaneId?: string, splitDirection: SplitDirection = "-h", ): Promise { - const { log } = await import("../../logger") + const [{ log }, { runTmuxCommand }] = await Promise.all([ + import("../../logger"), + import("../runner"), + ]) log("[spawnTmuxPane] called", { sessionId, @@ -49,9 +52,8 @@ export async function spawnTmuxPane( log("[spawnTmuxPane] all checks passed, spawning...") - 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 args = [ "split-window", @@ -64,29 +66,21 @@ export async function spawnTmuxPane( opencodeCmd, ] - const proc = spawn([tmux, ...args], { stdout: "pipe", stderr: "pipe" }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const paneId = stdout.trim() + const result = await runTmuxCommand(tmux, args) + const paneId = result.output - if (exitCode !== 0 || !paneId) { + if (result.exitCode !== 0 || !paneId) { 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("[spawnTmuxPane] WARNING: failed to set pane title", { paneId, title, - exitCode: titleExitCode, - stderr: titleStderr.trim(), + exitCode: titleResult.exitCode, + stderr: titleResult.stderr.trim(), }) }