mirror of
https://mirror.skon.top/github.com/code-yeongyu/oh-my-opencode
synced 2026-04-21 07:50:31 +08:00
fix(tmux): pass --dir to opencode attach in subagent spawn helpers
User-visible symptom: tmux subagent panes "open then immediately close" even after wait-before-attach + attachable-status + session.idle retry fixes. Team-layout panes do not exhibit this because they already pass --dir. Root cause: pane-spawn.ts / window-spawn.ts / session-spawn.ts / pane-replace.ts construct `opencode attach <url> --session <id>` without --dir. opencode attach inherits the tmux pane's cwd rather than the session's directory, which can cause the TUI to exit at startup and the pane to die. Team layout has always passed --dir and works correctly. This commit threads the subagent manager's working directory through the four spawn helpers, bringing them in line with the team-layout contract. Attach now receives the correct directory for every subagent pane. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -10,6 +10,7 @@ export interface ActionResult {
|
||||
|
||||
export interface ExecuteContext {
|
||||
config: TmuxConfig
|
||||
directory: string
|
||||
serverUrl: string
|
||||
windowState: WindowState
|
||||
}
|
||||
@@ -55,6 +56,7 @@ export async function executeActionWithDeps(
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl,
|
||||
ctx.directory,
|
||||
)
|
||||
return {
|
||||
success: result.success,
|
||||
@@ -67,6 +69,7 @@ export async function executeActionWithDeps(
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl,
|
||||
ctx.directory,
|
||||
action.targetPaneId,
|
||||
action.splitDirection,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,9 @@ import { executeActionWithDeps } from "./action-executor-core"
|
||||
import type { ActionExecutorDeps, ExecuteContext } from "./action-executor-core"
|
||||
import type { WindowState } from "./types"
|
||||
|
||||
const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: "%7" }))
|
||||
type SpawnPaneResult = Awaited<ReturnType<ActionExecutorDeps["spawnTmuxPane"]>>
|
||||
|
||||
const mockSpawnTmuxPane = mock(async (): Promise<SpawnPaneResult> => ({ success: true, paneId: "%7" }))
|
||||
const mockCloseTmuxPane = mock(async () => true)
|
||||
const mockEnforceMainPaneWidth = mock(async () => undefined)
|
||||
const mockReplaceTmuxPane = mock(async () => ({ success: true, paneId: "%7" }))
|
||||
@@ -21,6 +23,7 @@ const mockDeps: ActionExecutorDeps = {
|
||||
function createConfig(overrides?: Partial<TmuxConfig>): TmuxConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
isolation: "inline",
|
||||
layout: "main-horizontal",
|
||||
main_pane_size: 55,
|
||||
main_pane_min_width: 120,
|
||||
@@ -50,6 +53,7 @@ function createWindowState(overrides?: Partial<WindowState>): WindowState {
|
||||
function createContext(overrides?: Partial<ExecuteContext>): ExecuteContext {
|
||||
return {
|
||||
config: createConfig(),
|
||||
directory: "/tmp/omo-project",
|
||||
serverUrl: "http://localhost:4096",
|
||||
windowState: createWindowState(),
|
||||
...overrides,
|
||||
@@ -90,7 +94,7 @@ describe("executeAction", () => {
|
||||
|
||||
test("does not apply layout when spawn fails", async () => {
|
||||
// given
|
||||
mockSpawnTmuxPane.mockImplementationOnce(async () => ({ success: false }))
|
||||
mockSpawnTmuxPane.mockImplementation(async (): Promise<SpawnPaneResult> => ({ success: false }))
|
||||
|
||||
// when
|
||||
const result = await executeActionWithDeps(
|
||||
@@ -109,5 +113,6 @@ describe("executeAction", () => {
|
||||
expect(result).toEqual({ success: false, paneId: undefined })
|
||||
expect(mockApplyLayout).not.toHaveBeenCalled()
|
||||
expect(mockEnforceMainPaneWidth).not.toHaveBeenCalled()
|
||||
mockSpawnTmuxPane.mockImplementation(async (): Promise<SpawnPaneResult> => ({ success: true, paneId: "%7" }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,10 +10,7 @@ import {
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { queryWindowState } from "./pane-state-querier"
|
||||
import { log } from "../../shared"
|
||||
import type {
|
||||
ActionResult,
|
||||
ActionExecutorDeps,
|
||||
} from "./action-executor-core"
|
||||
import type { ActionResult } from "./action-executor-core"
|
||||
|
||||
export type { ActionExecutorDeps, ActionResult } from "./action-executor-core"
|
||||
|
||||
@@ -25,6 +22,7 @@ export interface ExecuteActionsResult {
|
||||
|
||||
export interface ExecuteContext {
|
||||
config: TmuxConfig
|
||||
directory: string
|
||||
serverUrl: string
|
||||
windowState: WindowState
|
||||
sourcePaneId?: string
|
||||
@@ -79,10 +77,11 @@ export async function executeAction(
|
||||
const result = await replaceTmuxPane(
|
||||
action.paneId,
|
||||
action.newSessionId,
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl
|
||||
)
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl,
|
||||
ctx.directory,
|
||||
)
|
||||
if (result.success) {
|
||||
await enforceLayoutAndMainPane(ctx)
|
||||
}
|
||||
@@ -94,12 +93,13 @@ export async function executeAction(
|
||||
|
||||
const result = await spawnTmuxPane(
|
||||
action.sessionId,
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl,
|
||||
action.targetPaneId,
|
||||
action.splitDirection
|
||||
)
|
||||
action.description,
|
||||
ctx.config,
|
||||
ctx.serverUrl,
|
||||
ctx.directory,
|
||||
action.targetPaneId,
|
||||
action.splitDirection
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
await enforceLayoutAndMainPane(ctx)
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface SessionPollingController {
|
||||
export function createSessionPollingController(params: {
|
||||
client: OpencodeClient
|
||||
tmuxConfig: TmuxConfig
|
||||
directory: string
|
||||
serverUrl: string
|
||||
sourcePaneId: string | undefined
|
||||
sessions: Map<string, TrackedSession>
|
||||
@@ -49,7 +50,12 @@ export function createSessionPollingController(params: {
|
||||
if (state) {
|
||||
await executeAction(
|
||||
{ type: "close", paneId: tracked.paneId, sessionId },
|
||||
{ config: params.tmuxConfig, serverUrl: params.serverUrl, windowState: state },
|
||||
{
|
||||
config: params.tmuxConfig,
|
||||
directory: params.directory,
|
||||
serverUrl: params.serverUrl,
|
||||
windowState: state,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import type { TmuxConfig } from "../../../config/schema"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
import type { TmuxCommandResult } from "../runner"
|
||||
|
||||
const paneReplaceSpecifier = import.meta.resolve("./pane-replace")
|
||||
@@ -29,6 +30,29 @@ const isInsideTmuxMock = mock((): boolean => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "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)]
|
||||
}
|
||||
|
||||
async function loadReplaceTmuxPane(): Promise<typeof import("./pane-replace").replaceTmuxPane> {
|
||||
const module = await import(`${paneReplaceSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.replaceTmuxPane
|
||||
@@ -49,10 +73,18 @@ describe("replaceTmuxPane runner integration", () => {
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
runTmuxCommandMock
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
const tmuxCommandResults: TmuxCommandResult[] = [
|
||||
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
|
||||
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
|
||||
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
|
||||
]
|
||||
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
|
||||
const nextResult = tmuxCommandResults.shift()
|
||||
if (!nextResult) {
|
||||
throw new Error("No more tmux command results configured")
|
||||
}
|
||||
return nextResult
|
||||
})
|
||||
isInsideTmuxMock.mockReturnValue(true)
|
||||
getTmuxPathMock.mockResolvedValue("sh")
|
||||
})
|
||||
@@ -60,16 +92,24 @@ describe("replaceTmuxPane runner integration", () => {
|
||||
it("#given existing pane #when replaceTmuxPane called #then delegates send-keys, respawn-pane, and select-pane to shared runner", async () => {
|
||||
// given
|
||||
const replaceTmuxPane = await loadReplaceTmuxPane()
|
||||
const directory = "/tmp/omo-project/(replace)"
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
|
||||
// when
|
||||
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234")
|
||||
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
|
||||
|
||||
// then
|
||||
const sendKeysCall = getRunTmuxCommandCall(0)
|
||||
const respawnCall = getRunTmuxCommandCall(1)
|
||||
const selectPaneCall = getRunTmuxCommandCall(2)
|
||||
expect(result).toEqual({ success: true, paneId: "%42" })
|
||||
expect(runTmuxCommandMock.mock.calls).toEqual([
|
||||
[expect.any(String), ["send-keys", "-t", "%42", "C-c"]],
|
||||
[expect.any(String), expect.arrayContaining(["respawn-pane", "-k", "-t", "%42"])],
|
||||
[expect.any(String), ["select-pane", "-t", "%42", "-T", "omo-subagent-worker"]],
|
||||
])
|
||||
expect(sendKeysCall[1]).toEqual(["send-keys", "-t", "%42", "C-c"])
|
||||
expect(respawnCall[1].slice(0, 4)).toEqual(["respawn-pane", "-k", "-t", "%42"])
|
||||
expect(selectPaneCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||
const respawnCommand = respawnCall[1][4]
|
||||
if (respawnCommand === undefined) {
|
||||
throw new Error("Expected respawn-pane command")
|
||||
}
|
||||
expect(respawnCommand).toContain(` --dir ${escapedDirectory}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function replaceTmuxPane(
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
): Promise<SpawnPaneResult> {
|
||||
const [{ log }, { runTmuxCommand }] = await Promise.all([
|
||||
import("../../logger"),
|
||||
@@ -35,7 +36,8 @@ export async function replaceTmuxPane(
|
||||
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId} --dir ${escapedDirectory}"`
|
||||
|
||||
const result = await runTmuxCommand(tmux, ["respawn-pane", "-k", "-t", paneId, opencodeCmd])
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import type { TmuxConfig } from "../../../config/schema"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
import type { TmuxCommandResult } from "../runner"
|
||||
|
||||
const paneSpawnSpecifier = import.meta.resolve("./pane-spawn")
|
||||
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "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)]
|
||||
}
|
||||
|
||||
async function loadSpawnTmuxPane(): Promise<typeof import("./pane-spawn").spawnTmuxPane> {
|
||||
const module = await import(`${paneSpawnSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.spawnTmuxPane
|
||||
@@ -53,9 +77,17 @@ describe("spawnTmuxPane runner integration", () => {
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
runTmuxCommandMock
|
||||
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
const tmuxCommandResults: TmuxCommandResult[] = [
|
||||
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
|
||||
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
|
||||
]
|
||||
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
|
||||
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")
|
||||
@@ -64,19 +96,22 @@ describe("spawnTmuxPane runner integration", () => {
|
||||
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)"
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
|
||||
// when
|
||||
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "%0")
|
||||
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(runTmuxCommandMock.mock.calls[0]).toEqual([
|
||||
expect.any(String),
|
||||
expect.arrayContaining(["split-window", "-h", "-d", "-P", "-F", "#{pane_id}", "-t", "%0"]),
|
||||
])
|
||||
expect(runTmuxCommandMock.mock.calls[1]).toEqual([
|
||||
expect.any(String),
|
||||
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
|
||||
])
|
||||
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"])
|
||||
const splitCommand = firstCall[1][8]
|
||||
if (splitCommand === undefined) {
|
||||
throw new Error("Expected split-window command")
|
||||
}
|
||||
expect(splitCommand).toContain(` --dir ${escapedDirectory}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function spawnTmuxPane(
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
targetPaneId?: string,
|
||||
splitDirection: SplitDirection = "-h",
|
||||
): Promise<SpawnPaneResult> {
|
||||
@@ -53,7 +54,8 @@ export async function spawnTmuxPane(
|
||||
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const escapedUrl = shellEscapeForDoubleQuotedCommand(serverUrl)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId}"`
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${sessionId} --dir ${escapedDirectory}"`
|
||||
|
||||
const args = [
|
||||
"split-window",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import type { TmuxConfig } from "../../../config/schema"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
import type { TmuxCommandResult } from "../runner"
|
||||
|
||||
const sessionSpawnSpecifier = import.meta.resolve("./session-spawn")
|
||||
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "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)]
|
||||
}
|
||||
|
||||
async function loadSpawnTmuxSession(): Promise<typeof import("./session-spawn").spawnTmuxSession> {
|
||||
const module = await import(`${sessionSpawnSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.spawnTmuxSession
|
||||
@@ -53,11 +77,19 @@ describe("spawnTmuxSession runner integration", () => {
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
runTmuxCommandMock
|
||||
.mockResolvedValueOnce({ success: true, output: "120,40", stdout: "120,40", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: false, output: "", stdout: "", stderr: "", exitCode: 1 })
|
||||
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
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<TmuxCommandResult> => {
|
||||
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")
|
||||
@@ -66,24 +98,29 @@ describe("spawnTmuxSession runner integration", () => {
|
||||
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)"
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
|
||||
// when
|
||||
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "%0")
|
||||
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(runTmuxCommandMock).toHaveBeenNthCalledWith(1,
|
||||
expect.any(String),
|
||||
["display", "-p", "-t", "%0", "#{window_width},#{window_height}"],
|
||||
)
|
||||
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(2, expect.any(String), ["has-session", "-t", expect.stringContaining("omo-agents-")])
|
||||
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(3,
|
||||
expect.any(String),
|
||||
expect.arrayContaining(["new-session", "-d", "-s", expect.stringContaining("omo-agents-")]),
|
||||
)
|
||||
expect(runTmuxCommandMock).toHaveBeenNthCalledWith(4,
|
||||
expect.any(String),
|
||||
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
|
||||
)
|
||||
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"])
|
||||
const newSessionCommand = newSessionCall[1][newSessionCall[1].length - 1]
|
||||
if (newSessionCommand === undefined) {
|
||||
throw new Error("Expected new-session command")
|
||||
}
|
||||
expect(newSessionCommand).toContain(` --dir ${escapedDirectory}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function spawnTmuxSession(
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
sourcePaneId?: string,
|
||||
): Promise<SpawnPaneResult> {
|
||||
const [{ log }, { runTmuxCommand }] = await Promise.all([
|
||||
@@ -77,7 +78,8 @@ export async function spawnTmuxSession(
|
||||
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 escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"`
|
||||
|
||||
const sizeArgs: string[] = []
|
||||
if (sourcePaneId) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import type { TmuxConfig } from "../../../config/schema"
|
||||
import { shellEscapeForDoubleQuotedCommand } from "../../shell-env"
|
||||
import type { TmuxCommandResult } from "../runner"
|
||||
|
||||
const windowSpawnSpecifier = import.meta.resolve("./window-spawn")
|
||||
@@ -31,6 +32,29 @@ const isServerRunningMock = mock(async (): Promise<boolean> => true)
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "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)]
|
||||
}
|
||||
|
||||
async function loadSpawnTmuxWindow(): Promise<typeof import("./window-spawn").spawnTmuxWindow> {
|
||||
const module = await import(`${windowSpawnSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.spawnTmuxWindow
|
||||
@@ -53,9 +77,17 @@ describe("spawnTmuxWindow runner integration", () => {
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
runTmuxCommandMock
|
||||
.mockResolvedValueOnce({ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, output: "", stdout: "", stderr: "", exitCode: 0 })
|
||||
const tmuxCommandResults: TmuxCommandResult[] = [
|
||||
{ success: true, output: "%42", stdout: "%42", stderr: "", exitCode: 0 },
|
||||
{ success: true, output: "", stdout: "", stderr: "", exitCode: 0 },
|
||||
]
|
||||
runTmuxCommandMock.mockImplementation(async (): Promise<TmuxCommandResult> => {
|
||||
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")
|
||||
@@ -64,19 +96,22 @@ describe("spawnTmuxWindow runner integration", () => {
|
||||
it("#given healthy tmux environment #when spawnTmuxWindow called #then delegates new-window and select-pane to shared runner", async () => {
|
||||
// given
|
||||
const spawnTmuxWindow = await loadSpawnTmuxWindow()
|
||||
const directory = "/tmp/omo-project/(window)"
|
||||
const escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
|
||||
// when
|
||||
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234")
|
||||
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
|
||||
|
||||
// then
|
||||
const firstCall = getRunTmuxCommandCall(0)
|
||||
const secondCall = getRunTmuxCommandCall(1)
|
||||
expect(result).toEqual({ success: true, paneId: "%42" })
|
||||
expect(runTmuxCommandMock.mock.calls[0]).toEqual([
|
||||
expect.any(String),
|
||||
expect.arrayContaining(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"]),
|
||||
])
|
||||
expect(runTmuxCommandMock.mock.calls[1]).toEqual([
|
||||
expect.any(String),
|
||||
["select-pane", "-t", "%42", "-T", "omo-subagent-worker"],
|
||||
])
|
||||
expect(firstCall[1].slice(0, 7)).toEqual(["new-window", "-d", "-n", "omo-agents", "-P", "-F", "#{pane_id}"])
|
||||
expect(secondCall[1]).toEqual(["select-pane", "-t", "%42", "-T", "omo-subagent-worker"])
|
||||
const newWindowCommand = firstCall[1][7]
|
||||
if (newWindowCommand === undefined) {
|
||||
throw new Error("Expected new-window command")
|
||||
}
|
||||
expect(newWindowCommand).toContain(` --dir ${escapedDirectory}`)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ export async function spawnTmuxWindow(
|
||||
description: string,
|
||||
config: TmuxConfig,
|
||||
serverUrl: string,
|
||||
directory: string,
|
||||
): Promise<SpawnPaneResult> {
|
||||
const [{ log }, { runTmuxCommand }] = await Promise.all([
|
||||
import("../../logger"),
|
||||
@@ -51,7 +52,8 @@ export async function spawnTmuxWindow(
|
||||
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 escapedDirectory = shellEscapeForDoubleQuotedCommand(directory)
|
||||
const opencodeCmd = `${shell} -c "opencode attach ${escapedUrl} --session ${escapedSessionId} --dir ${escapedDirectory}"`
|
||||
|
||||
const args = [
|
||||
"new-window",
|
||||
|
||||
Reference in New Issue
Block a user