From 13b3bd9e72079299943aa825b18c3e23eea6ecb2 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:10 +0900 Subject: [PATCH] feat(tmux): add session-spawn utility with tests --- .../tmux/tmux-utils/session-spawn.test.ts | 164 ++++++++++++++++++ src/shared/tmux/tmux-utils/session-spawn.ts | 66 +++---- 2 files changed, 190 insertions(+), 40 deletions(-) create mode 100644 src/shared/tmux/tmux-utils/session-spawn.test.ts diff --git a/src/shared/tmux/tmux-utils/session-spawn.test.ts b/src/shared/tmux/tmux-utils/session-spawn.test.ts new file mode 100644 index 000000000..f5bf38aca --- /dev/null +++ b/src/shared/tmux/tmux-utils/session-spawn.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +import type { TmuxConfig } from "../../../config/schema" +import type { TmuxCommandResult } from "../runner" + +const sessionSpawnSpecifier = import.meta.resolve("./session-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: "", + stdout: "", + 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 getSpawnCommand(): string { + const newSessionCall = getRunTmuxCommandCall(2) + const newSessionCommand = newSessionCall[1][newSessionCall[1].length - 1] + if (newSessionCommand === undefined) { + throw new Error("Expected new-session command") + } + + return newSessionCommand +} + +async function loadSpawnTmuxSession(): Promise { + const module = await import(`${sessionSpawnSpecifier}?test=${crypto.randomUUID()}`) + return module.spawnTmuxSession +} + +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("spawnTmuxSession runner integration", () => { + beforeEach(() => { + mock.restore() + registerModuleMocks() + runTmuxCommandMock.mockClear() + isInsideTmuxMock.mockClear() + isServerRunningMock.mockClear() + getTmuxPathMock.mockClear() + logMock.mockClear() + + const tmuxCommandResults: TmuxCommandResult[] = [ + { success: true, output: "120,40", stdout: "120,40", stderr: "", exitCode: 0 }, + { success: false, output: "", stdout: "", stderr: "", exitCode: 1 }, + { 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 source pane available #when spawnTmuxSession called #then delegates display, has-session, new-session, and select-pane to shared runner", async () => { + // given + const spawnTmuxSession = await loadSpawnTmuxSession() + const directory = "/tmp/omo-project/(session)" + + // when + const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0") + + // then + const displayCall = getRunTmuxCommandCall(0) + const hasSessionCall = getRunTmuxCommandCall(1) + const newSessionCall = getRunTmuxCommandCall(2) + const selectPaneCall = getRunTmuxCommandCall(3) + expect(result).toEqual({ success: true, paneId: "%42" }) + expect(displayCall[1]).toEqual(["display", "-p", "-t", "%0", "#{window_width},#{window_height}"]) + expect(hasSessionCall[1][0]).toBe("has-session") + expect(hasSessionCall[1][1]).toBe("-t") + expect(hasSessionCall[1][2]?.startsWith("omo-agents-")).toBe(true) + expect(newSessionCall[1].slice(0, 4)).toEqual(["new-session", "-d", "-s", newSessionCall[1][3]]) + expect(String(newSessionCall[1][3]).startsWith("omo-agents-")).toBe(true) + expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]) + expect(getSpawnCommand()).toContain(` --dir '${directory}'`) + }) + + it("#given directory with spaces #when spawnTmuxSession called #then wraps --dir value in single quotes", async () => { + // given + const spawnTmuxSession = await loadSpawnTmuxSession() + + // when + await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0") + + // then + expect(getSpawnCommand()).toContain("--dir '/path with spaces/here'") + }) + + it("#given empty directory #when spawnTmuxSession called #then falls back to process cwd", async () => { + // given + const spawnTmuxSession = await loadSpawnTmuxSession() + + // when + await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0") + + // then + expect(getSpawnCommand()).toContain(`--dir '${process.cwd()}'`) + }) + + it("#given directory with single quotes #when spawnTmuxSession called #then escapes the value with POSIX-safe single quoting", async () => { + // given + const spawnTmuxSession = await loadSpawnTmuxSession() + + // when + await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0") + + // then + expect(getSpawnCommand()).toContain("--dir '/path/with'\\''quote'") + }) +}) diff --git a/src/shared/tmux/tmux-utils/session-spawn.ts b/src/shared/tmux/tmux-utils/session-spawn.ts index f5f3bbf7e..16aab5012 100644 --- a/src/shared/tmux/tmux-utils/session-spawn.ts +++ b/src/shared/tmux/tmux-utils/session-spawn.ts @@ -1,10 +1,10 @@ -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 { runTmuxCommand as RunTmuxCommand } from "../runner" import { isInsideTmux } from "./environment" import { isServerRunning } from "./server-health" -import { shellEscapeForDoubleQuotedCommand } from "../../shell-env" +import { shellSingleQuote } from "../../shell-env" const ISOLATED_SESSION_NAME_PREFIX = "omo-agents" @@ -15,28 +15,21 @@ export function getIsolatedSessionName(pid: number = process.pid): string { async function getWindowDimensions( tmux: string, sourcePaneId: string, + runTmuxCommand: typeof RunTmuxCommand, ): Promise<{ width: number; height: number } | null> { - const proc = spawn( - [tmux, "display", "-p", "-t", sourcePaneId, "#{window_width},#{window_height}"], - { stdout: "pipe", stderr: "pipe" }, - ) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() + const result = await runTmuxCommand(tmux, ["display", "-p", "-t", sourcePaneId, "#{window_width},#{window_height}"]) - if (exitCode !== 0) return null + if (result.exitCode !== 0) return null - const [width, height] = stdout.trim().split(",").map(Number) + const [width, height] = result.output.trim().split(",").map(Number) if (Number.isNaN(width) || Number.isNaN(height)) return null return { width, height } } -async function sessionExists(tmux: string, sessionName: string): Promise { - const proc = spawn([tmux, "has-session", "-t", sessionName], { - stdout: "ignore", - stderr: "ignore", - }) - return (await proc.exited) === 0 +async function sessionExists(tmux: string, sessionName: string, runTmuxCommand: typeof RunTmuxCommand): Promise { + const result = await runTmuxCommand(tmux, ["has-session", "-t", sessionName]) + return result.exitCode === 0 } export async function spawnTmuxSession( @@ -44,9 +37,13 @@ export async function spawnTmuxSession( description: string, config: TmuxConfig, serverUrl: string, + directory: string, sourcePaneId?: string, ): Promise { - const { log } = await import("../../logger") + const [{ log }, { runTmuxCommand }] = await Promise.all([ + import("../../logger"), + import("../runner"), + ]) log("[spawnTmuxSession] called", { sessionId, @@ -78,21 +75,19 @@ export async function spawnTmuxSession( log("[spawnTmuxSession] all checks passed, creating isolated session...") - const shell = process.env.SHELL || "/bin/sh" - const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl) - const escapedSessionId = shellEscapeForDoubleQuotedCommand(sessionId) - const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId}"` + const effectiveDirectory = directory || process.cwd() + const opencodeCmd = `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(sessionId)} --dir ${shellSingleQuote(effectiveDirectory)}` const sizeArgs: string[] = [] if (sourcePaneId) { - const dims = await getWindowDimensions(tmux, sourcePaneId) + const dims = await getWindowDimensions(tmux, sourcePaneId, runTmuxCommand) if (dims) { sizeArgs.push("-x", String(dims.width), "-y", String(dims.height)) } } const isolatedSessionName = getIsolatedSessionName() - const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName) + const sessionAlreadyExists = await sessionExists(tmux, isolatedSessionName, runTmuxCommand) const args = sessionAlreadyExists ? [ @@ -117,31 +112,22 @@ export async function spawnTmuxSession( sessionName: isolatedSessionName, }) - 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) { - const stderr = await new Response(proc.stderr).text() - log("[spawnTmuxSession] FAILED", { exitCode, stderr: stderr.trim() }) + if (result.exitCode !== 0 || !paneId) { + log("[spawnTmuxSession] FAILED", { 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("[spawnTmuxSession] WARNING: failed to set pane title", { paneId, title, - exitCode: titleExitCode, - stderr: titleStderr.trim(), + exitCode: titleResult.exitCode, + stderr: titleResult.stderr.trim(), }) }