From 90e6e0cdcea2ea03acf81efa2075f9faba0a0d2a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 10:45:13 +0900 Subject: [PATCH] feat(openclaw): integrate typed tmux runner for external dispatch --- src/openclaw/__tests__/tmux.test.ts | 148 +++++++++++++++++++++++++++- src/openclaw/tmux.ts | 63 +++++------- 2 files changed, 167 insertions(+), 44 deletions(-) diff --git a/src/openclaw/__tests__/tmux.test.ts b/src/openclaw/__tests__/tmux.test.ts index 790a1bbe0..c7c856eeb 100644 --- a/src/openclaw/__tests__/tmux.test.ts +++ b/src/openclaw/__tests__/tmux.test.ts @@ -1,13 +1,153 @@ -import { describe, expect, test } from "bun:test" -import { analyzePaneContent } from "../tmux" +/// + +import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type MockTmuxCommandResult = { + success: boolean + output: string + stdout: string + stderr: string + exitCode: number +} + +const runTmuxCommandMock = mock( + async (): Promise => ({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, + }), +) + +const getTmuxPathMock = mock(async (): Promise => "/mock/tmux") + +let tmuxModule: typeof import("../tmux") + +beforeAll(async () => { + mock.module("../../shared/tmux/runner", () => ({ + runTmuxCommand: runTmuxCommandMock, + })) + + mock.module("../../tools/interactive-bash/tmux-path-resolver", () => ({ + getTmuxPath: getTmuxPathMock, + })) + + tmuxModule = await import("../tmux") +}) + +beforeEach(() => { + runTmuxCommandMock.mockReset() + getTmuxPathMock.mockReset() + getTmuxPathMock.mockResolvedValue("/mock/tmux") +}) + +afterAll(() => { + mock.restore() +}) describe("openclaw tmux helpers", () => { test("analyzePaneContent recognizes the opencode welcome prompt", () => { + // given const content = "opencode\nAsk anything...\nRun /help" - expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1) + + // when + const result = tmuxModule.analyzePaneContent(content) + + // then + expect(result.confidence).toBe(1) }) test("analyzePaneContent returns zero confidence for empty content", () => { - expect(analyzePaneContent(null).confidence).toBe(0) + // given + const content = null + + // when + const result = tmuxModule.analyzePaneContent(content) + + // then + expect(result.confidence).toBe(0) + }) + + test("isTmuxAvailable delegates version checks through runTmuxCommand", async () => { + // given + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "tmux 3.5a", + stdout: "tmux 3.5a", + stderr: "", + exitCode: 0, + }) + + // when + const result = await tmuxModule.isTmuxAvailable() + + // then + expect(result).toBe(true) + expect(getTmuxPathMock).toHaveBeenCalledTimes(1) + expect(runTmuxCommandMock).toHaveBeenCalledTimes(1) + expect(runTmuxCommandMock).toHaveBeenCalledWith("/mock/tmux", ["-V"]) + }) + + test("getTmuxSessionName delegates session lookup through runTmuxCommand", async () => { + // given + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "team-mode\n", + stdout: "team-mode\n", + stderr: "", + exitCode: 0, + }) + + // when + const result = await tmuxModule.getTmuxSessionName() + + // then + expect(result).toBe("team-mode") + expect(runTmuxCommandMock).toHaveBeenCalledWith("/mock/tmux", ["display-message", "-p", "#S"]) + }) + + test("captureTmuxPane delegates pane capture through runTmuxCommand", async () => { + // given + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "pane output\n", + stdout: "pane output\n", + stderr: "", + exitCode: 0, + }) + + // when + const result = await tmuxModule.captureTmuxPane("%42", 30) + + // then + expect(result).toBe("pane output") + expect(runTmuxCommandMock).toHaveBeenCalledWith("/mock/tmux", ["capture-pane", "-p", "-t", "%42", "-S", "-30"]) + }) + + test("sendToPane delegates literal text and Enter through runTmuxCommand", async () => { + // given + runTmuxCommandMock.mockResolvedValue({ + success: true, + output: "", + stdout: "", + stderr: "", + exitCode: 0, + }) + + // when + const result = await tmuxModule.sendToPane("%42", "hello", true) + + // then + expect(result).toBe(true) + expect(runTmuxCommandMock).toHaveBeenCalledTimes(2) + expect(runTmuxCommandMock.mock.calls[0]).toEqual([ + "/mock/tmux", + ["send-keys", "-t", "%42", "-l", "--", "hello"], + ]) + expect(runTmuxCommandMock.mock.calls[1]).toEqual([ + "/mock/tmux", + ["send-keys", "-t", "%42", "Enter"], + ]) }) }) diff --git a/src/openclaw/tmux.ts b/src/openclaw/tmux.ts index 47b04c45a..d7dfaff49 100644 --- a/src/openclaw/tmux.ts +++ b/src/openclaw/tmux.ts @@ -1,4 +1,14 @@ -import { spawn } from "../shared/bun-spawn-shim" +import { runTmuxCommand } from "../shared/tmux/runner" +import { getTmuxPath } from "../tools/interactive-bash/tmux-path-resolver" + +async function runOpenClawTmuxCommand(args: string[]) { + const tmuxPath = await getTmuxPath() + if (!tmuxPath) { + return null + } + + return runTmuxCommand(tmuxPath, args) +} export function getCurrentTmuxSession(): string | null { const env = process.env.TMUX @@ -9,15 +19,9 @@ export function getCurrentTmuxSession(): string | null { export async function getTmuxSessionName(): Promise { try { - const proc = spawn(["tmux", "display-message", "-p", "#S"], { - stdout: "pipe", - stderr: "ignore", - }) - const outputPromise = new Response(proc.stdout).text() - await proc.exited - const output = await outputPromise - if (proc.exitCode !== 0) return null - return output.trim() || null + const result = await runOpenClawTmuxCommand(["display-message", "-p", "#S"]) + if (!result?.success) return null + return result.output.trim() || null } catch { return null } @@ -25,18 +29,9 @@ export async function getTmuxSessionName(): Promise { export async function captureTmuxPane(paneId: string, lines = 15): Promise { try { - const proc = spawn( - ["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`], - { - stdout: "pipe", - stderr: "ignore", - }, - ) - const outputPromise = new Response(proc.stdout).text() - await proc.exited - const output = await outputPromise - if (proc.exitCode !== 0) return null - return output.trim() || null + const result = await runOpenClawTmuxCommand(["capture-pane", "-p", "-t", paneId, "-S", `-${lines}`]) + if (!result?.success) return null + return result.output.trim() || null } catch { return null } @@ -44,21 +39,13 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise { try { - const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], { - stdout: "ignore", - stderr: "ignore", - }) - await literalProc.exited - if (literalProc.exitCode !== 0) return false + const literalResult = await runOpenClawTmuxCommand(["send-keys", "-t", paneId, "-l", "--", text]) + if (!literalResult?.success) return false if (!confirm) return true - const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], { - stdout: "ignore", - stderr: "ignore", - }) - await enterProc.exited - return enterProc.exitCode === 0 + const enterResult = await runOpenClawTmuxCommand(["send-keys", "-t", paneId, "Enter"]) + return enterResult?.success ?? false } catch { return false } @@ -66,12 +53,8 @@ export async function sendToPane(paneId: string, text: string, confirm = true): export async function isTmuxAvailable(): Promise { try { - const proc = spawn(["tmux", "-V"], { - stdout: "ignore", - stderr: "ignore", - }) - await proc.exited - return proc.exitCode === 0 + const result = await runOpenClawTmuxCommand(["-V"]) + return result?.success ?? false } catch { return false }