feat(openclaw): integrate typed tmux runner for external dispatch
This commit is contained in:
@@ -1,13 +1,153 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
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<MockTmuxCommandResult> => ({
|
||||||
|
success: true,
|
||||||
|
output: "",
|
||||||
|
stdout: "",
|
||||||
|
stderr: "",
|
||||||
|
exitCode: 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTmuxPathMock = mock(async (): Promise<string | null> => "/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", () => {
|
describe("openclaw tmux helpers", () => {
|
||||||
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
|
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
|
||||||
|
// given
|
||||||
const content = "opencode\nAsk anything...\nRun /help"
|
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", () => {
|
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"],
|
||||||
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+23
-40
@@ -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 {
|
export function getCurrentTmuxSession(): string | null {
|
||||||
const env = process.env.TMUX
|
const env = process.env.TMUX
|
||||||
@@ -9,15 +19,9 @@ export function getCurrentTmuxSession(): string | null {
|
|||||||
|
|
||||||
export async function getTmuxSessionName(): Promise<string | null> {
|
export async function getTmuxSessionName(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const proc = spawn(["tmux", "display-message", "-p", "#S"], {
|
const result = await runOpenClawTmuxCommand(["display-message", "-p", "#S"])
|
||||||
stdout: "pipe",
|
if (!result?.success) return null
|
||||||
stderr: "ignore",
|
return result.output.trim() || null
|
||||||
})
|
|
||||||
const outputPromise = new Response(proc.stdout).text()
|
|
||||||
await proc.exited
|
|
||||||
const output = await outputPromise
|
|
||||||
if (proc.exitCode !== 0) return null
|
|
||||||
return output.trim() || null
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -25,18 +29,9 @@ export async function getTmuxSessionName(): Promise<string | null> {
|
|||||||
|
|
||||||
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
|
export async function captureTmuxPane(paneId: string, lines = 15): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const proc = spawn(
|
const result = await runOpenClawTmuxCommand(["capture-pane", "-p", "-t", paneId, "-S", `-${lines}`])
|
||||||
["tmux", "capture-pane", "-p", "-t", paneId, "-S", `-${lines}`],
|
if (!result?.success) return null
|
||||||
{
|
return result.output.trim() || null
|
||||||
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
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -44,21 +39,13 @@ export async function captureTmuxPane(paneId: string, lines = 15): Promise<strin
|
|||||||
|
|
||||||
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
|
export async function sendToPane(paneId: string, text: string, confirm = true): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const literalProc = spawn(["tmux", "send-keys", "-t", paneId, "-l", "--", text], {
|
const literalResult = await runOpenClawTmuxCommand(["send-keys", "-t", paneId, "-l", "--", text])
|
||||||
stdout: "ignore",
|
if (!literalResult?.success) return false
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
await literalProc.exited
|
|
||||||
if (literalProc.exitCode !== 0) return false
|
|
||||||
|
|
||||||
if (!confirm) return true
|
if (!confirm) return true
|
||||||
|
|
||||||
const enterProc = spawn(["tmux", "send-keys", "-t", paneId, "Enter"], {
|
const enterResult = await runOpenClawTmuxCommand(["send-keys", "-t", paneId, "Enter"])
|
||||||
stdout: "ignore",
|
return enterResult?.success ?? false
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
await enterProc.exited
|
|
||||||
return enterProc.exitCode === 0
|
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -66,12 +53,8 @@ export async function sendToPane(paneId: string, text: string, confirm = true):
|
|||||||
|
|
||||||
export async function isTmuxAvailable(): Promise<boolean> {
|
export async function isTmuxAvailable(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const proc = spawn(["tmux", "-V"], {
|
const result = await runOpenClawTmuxCommand(["-V"])
|
||||||
stdout: "ignore",
|
return result?.success ?? false
|
||||||
stderr: "ignore",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
return proc.exitCode === 0
|
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user