fix: follow up cmux probing and tmux session resolution
This commit is contained in:
@@ -578,7 +578,7 @@ The runtime evaluates tmux and cmux availability to determine operating mode:
|
||||
| ------------------ | ---------------------------------------------- | ------------ | ------------- |
|
||||
| `cmux-shim` | Live cmux + live tmux pane control | tmux | cmux (if capable), else desktop |
|
||||
| `tmux-only` | Live tmux pane control, no live cmux | tmux | desktop |
|
||||
| `cmux-notify-only` | Live cmux (no pane control), tmux unavailable | none | cmux (if capable), else desktop |
|
||||
| `cmux-notify-only` | Live cmux, tmux pane control unavailable | none | cmux (if capable), else desktop |
|
||||
| `none` | Neither tmux nor cmux available | none | desktop |
|
||||
|
||||
#### Backend Precedence Semantics
|
||||
|
||||
75
src/openclaw/__tests__/index.test.ts
Normal file
75
src/openclaw/__tests__/index.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
import type { OpenClawConfig } from "../types"
|
||||
|
||||
const wakeGatewayMock = mock(async () => ({
|
||||
gateway: "http-gateway",
|
||||
success: true,
|
||||
statusCode: 200,
|
||||
}))
|
||||
const wakeCommandGatewayMock = mock(async () => ({
|
||||
gateway: "command-gateway",
|
||||
success: true,
|
||||
}))
|
||||
const interpolateInstructionMock = mock((template: string) => template)
|
||||
const getCurrentTmuxSessionMock = mock(async () => "workspace-main")
|
||||
const captureTmuxPaneMock = mock(async () => null)
|
||||
const startReplyListenerMock = mock(async () => {})
|
||||
const stopReplyListenerMock = mock(() => {})
|
||||
|
||||
mock.module("../dispatcher", () => ({
|
||||
wakeGateway: wakeGatewayMock,
|
||||
wakeCommandGateway: wakeCommandGatewayMock,
|
||||
interpolateInstruction: interpolateInstructionMock,
|
||||
}))
|
||||
|
||||
mock.module("../tmux", () => ({
|
||||
getCurrentTmuxSession: getCurrentTmuxSessionMock,
|
||||
captureTmuxPane: captureTmuxPaneMock,
|
||||
}))
|
||||
|
||||
mock.module("../reply-listener", () => ({
|
||||
startReplyListener: startReplyListenerMock,
|
||||
stopReplyListener: stopReplyListenerMock,
|
||||
}))
|
||||
|
||||
const { wakeOpenClaw } = await import("../index")
|
||||
|
||||
describe("wakeOpenClaw tmux session resolution", () => {
|
||||
beforeEach(() => {
|
||||
wakeGatewayMock.mockClear()
|
||||
wakeCommandGatewayMock.mockClear()
|
||||
interpolateInstructionMock.mockClear()
|
||||
getCurrentTmuxSessionMock.mockClear()
|
||||
captureTmuxPaneMock.mockClear()
|
||||
startReplyListenerMock.mockClear()
|
||||
stopReplyListenerMock.mockClear()
|
||||
})
|
||||
|
||||
test("awaits asynchronous tmux session lookup before dispatch", async () => {
|
||||
const config: OpenClawConfig = {
|
||||
enabled: true,
|
||||
gateways: {
|
||||
commandGateway: {
|
||||
type: "command",
|
||||
method: "POST",
|
||||
command: "echo {{tmuxSession}}",
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
"session-start": {
|
||||
enabled: true,
|
||||
gateway: "commandGateway",
|
||||
instruction: "tmux session: {{tmuxSession}}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
await wakeOpenClaw(config, "session-start", {})
|
||||
|
||||
expect(getCurrentTmuxSessionMock).toHaveBeenCalledTimes(1)
|
||||
expect(interpolateInstructionMock).toHaveBeenCalledTimes(1)
|
||||
expect(interpolateInstructionMock.mock.calls[0]?.[1]?.tmuxSession).toBe("workspace-main")
|
||||
expect(wakeCommandGatewayMock).toHaveBeenCalledTimes(1)
|
||||
expect(wakeCommandGatewayMock.mock.calls[0]?.[2]?.tmuxSession).toBe("workspace-main")
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { analyzePaneContent } from "../tmux"
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { resetMultiplexerPathCacheForTesting } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { resetResolvedMultiplexerRuntimeForTesting } from "../../shared/tmux"
|
||||
import { analyzePaneContent, getCurrentTmuxSession } from "../tmux"
|
||||
|
||||
describe("openclaw tmux helpers", () => {
|
||||
beforeEach(() => {
|
||||
resetMultiplexerPathCacheForTesting()
|
||||
resetResolvedMultiplexerRuntimeForTesting()
|
||||
})
|
||||
|
||||
test("analyzePaneContent recognizes the opencode welcome prompt", () => {
|
||||
const content = "opencode\nAsk anything...\nRun /help"
|
||||
expect(analyzePaneContent(content).confidence).toBeGreaterThanOrEqual(1)
|
||||
@@ -10,4 +17,38 @@ describe("openclaw tmux helpers", () => {
|
||||
test("analyzePaneContent returns zero confidence for empty content", () => {
|
||||
expect(analyzePaneContent(null).confidence).toBe(0)
|
||||
})
|
||||
|
||||
test("getCurrentTmuxSession does not synthesize a session from TMUX_PANE", async () => {
|
||||
const originalTmux = process.env.TMUX
|
||||
const originalTmuxPane = process.env.TMUX_PANE
|
||||
const originalDisableTmuxFlag = process.env.OH_MY_OPENCODE_DISABLE_TMUX
|
||||
|
||||
try {
|
||||
process.env.TMUX = "/tmp/tmux-501/default,1,0"
|
||||
process.env.TMUX_PANE = "%42"
|
||||
process.env.OH_MY_OPENCODE_DISABLE_TMUX = "1"
|
||||
|
||||
const sessionName = await getCurrentTmuxSession()
|
||||
|
||||
expect(sessionName).toBeNull()
|
||||
} finally {
|
||||
if (originalTmux === undefined) {
|
||||
delete process.env.TMUX
|
||||
} else {
|
||||
process.env.TMUX = originalTmux
|
||||
}
|
||||
|
||||
if (originalTmuxPane === undefined) {
|
||||
delete process.env.TMUX_PANE
|
||||
} else {
|
||||
process.env.TMUX_PANE = originalTmuxPane
|
||||
}
|
||||
|
||||
if (originalDisableTmuxFlag === undefined) {
|
||||
delete process.env.OH_MY_OPENCODE_DISABLE_TMUX
|
||||
} else {
|
||||
process.env.OH_MY_OPENCODE_DISABLE_TMUX = originalDisableTmuxFlag
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,7 +55,10 @@ export async function wakeOpenClaw(
|
||||
...(replyThread !== undefined && { replyThread }),
|
||||
}
|
||||
|
||||
const tmuxSession = enrichedContext.tmuxSession ?? getCurrentTmuxSession() ?? undefined
|
||||
const tmuxSession =
|
||||
enrichedContext.tmuxSession
|
||||
?? (await getCurrentTmuxSession())
|
||||
?? undefined
|
||||
|
||||
let tmuxTail = enrichedContext.tmuxTail
|
||||
if (!tmuxTail && (event === "stop" || event === "session-end") && process.env.TMUX) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isInsideTmux,
|
||||
} from "../shared/tmux"
|
||||
|
||||
export function getCurrentTmuxSession(): string | null {
|
||||
export async function getCurrentTmuxSession(): Promise<string | null> {
|
||||
const resolvedMultiplexer = getResolvedMultiplexerRuntime()
|
||||
if (resolvedMultiplexer && resolvedMultiplexer.paneBackend !== "tmux") {
|
||||
return null
|
||||
@@ -19,9 +19,7 @@ export function getCurrentTmuxSession(): string | null {
|
||||
const paneId = getCurrentPaneId(resolvedMultiplexer ?? undefined)
|
||||
if (!paneId) return null
|
||||
|
||||
const match = paneId.match(/(\d+)$/)
|
||||
return match ? `session-${match[1]}` : null
|
||||
// Reference tmux.js gets session name via `tmux display-message -p '#S'`
|
||||
return getTmuxSessionName()
|
||||
}
|
||||
|
||||
export async function getTmuxSessionName(): Promise<string | null> {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { beforeEach, describe, expect, spyOn, test } from "bun:test"
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
classifyCmuxEndpoint,
|
||||
isConnectionRefusedText,
|
||||
probeCmuxNotificationCapability,
|
||||
probeCmuxReachability,
|
||||
probeTmuxRuntime,
|
||||
resetMultiplexerPathCacheForTesting,
|
||||
@@ -55,6 +59,62 @@ describe("tmux-path-resolver probe environment", () => {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("probeTmuxRuntime honors explicit PATH removal in custom environment", async () => {
|
||||
const whichSpy = spyOn(Bun, "which").mockImplementation(() => null)
|
||||
|
||||
try {
|
||||
await probeTmuxRuntime({
|
||||
environment: {
|
||||
PATH: undefined,
|
||||
TMUX: "/tmp/tmux-501/default,1,0",
|
||||
TMUX_PANE: "%1",
|
||||
},
|
||||
})
|
||||
|
||||
expect(whichSpy).toHaveBeenCalledTimes(1)
|
||||
expect(whichSpy.mock.calls[0]).toEqual([
|
||||
"tmux",
|
||||
{ PATH: "" },
|
||||
])
|
||||
} finally {
|
||||
whichSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("probeCmuxNotificationCapability returns promptly after probe timeout", async () => {
|
||||
const tempDirectory = mkdtempSync(join(tmpdir(), "tmux-path-resolver-timeout-"))
|
||||
const fakeCmuxPath = join(tempDirectory, "cmux")
|
||||
const slowCmuxScript = `#!/bin/sh
|
||||
if [ "$1" = "notify" ]; then
|
||||
trap '' TERM
|
||||
/bin/sleep 1
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
`
|
||||
|
||||
writeFileSync(fakeCmuxPath, slowCmuxScript)
|
||||
chmodSync(fakeCmuxPath, 0o755)
|
||||
|
||||
try {
|
||||
const startedAt = Date.now()
|
||||
const probe = await probeCmuxNotificationCapability({
|
||||
cmuxPath: fakeCmuxPath,
|
||||
environment: {
|
||||
PATH: tempDirectory,
|
||||
},
|
||||
timeoutMs: 40,
|
||||
})
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
|
||||
expect(probe.failureKind).toBe("timeout")
|
||||
expect(elapsedMs).toBeLessThan(500)
|
||||
} finally {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("tmux-path-resolver cmux endpoint helpers", () => {
|
||||
|
||||
@@ -240,7 +240,16 @@ async function runProbeCommand(
|
||||
clearTimeout(timeoutHandle)
|
||||
}
|
||||
|
||||
const exitCode = timedOut ? null : await proc.exited.catch(() => null)
|
||||
if (timedOut) {
|
||||
return {
|
||||
exitCode: null,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: true,
|
||||
}
|
||||
}
|
||||
|
||||
const exitCode = await proc.exited.catch(() => null)
|
||||
const stdout = await new Response(proc.stdout).text().catch(() => "")
|
||||
const stderr = await new Response(proc.stderr).text().catch(() => "")
|
||||
|
||||
@@ -266,8 +275,12 @@ function findCommandPath(
|
||||
): string | null {
|
||||
try {
|
||||
const probeEnvironment = toProbeEnvironment(environment)
|
||||
const whichOptions =
|
||||
probeEnvironment.PATH !== undefined
|
||||
const hasExplicitPathOverride =
|
||||
environment !== undefined
|
||||
&& Object.prototype.hasOwnProperty.call(environment, "PATH")
|
||||
const whichOptions = hasExplicitPathOverride
|
||||
? { PATH: probeEnvironment.PATH ?? "" }
|
||||
: probeEnvironment.PATH !== undefined
|
||||
? { PATH: probeEnvironment.PATH }
|
||||
: undefined
|
||||
|
||||
|
||||
Reference in New Issue
Block a user