fix(tmux): inject pane action dependencies

This commit is contained in:
YeonGyu-Kim
2026-05-15 17:54:43 +09:00
parent 7a94cc72be
commit 8dcbccf063
6 changed files with 106 additions and 74 deletions
@@ -98,6 +98,8 @@ const mockTmuxDeps: TmuxUtilDeps = {
getCurrentPaneId: mockGetCurrentPaneId, getCurrentPaneId: mockGetCurrentPaneId,
queryWindowState: mockQueryWindowState, queryWindowState: mockQueryWindowState,
waitForSessionReady: mockWaitForSessionReady, waitForSessionReady: mockWaitForSessionReady,
executeActions: mockExecuteActions,
executeAction: mockExecuteAction,
log: (...args) => sharedModule.log(...args), log: (...args) => sharedModule.log(...args),
} }
+10 -6
View File
@@ -55,6 +55,8 @@ export interface TmuxUtilDeps {
getCurrentPaneId: () => string | undefined getCurrentPaneId: () => string | undefined
queryWindowState: (paneId: string) => Promise<WindowState | null> queryWindowState: (paneId: string) => Promise<WindowState | null>
waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise<boolean> waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise<boolean>
executeActions: typeof executeActions
executeAction: typeof executeAction
log: typeof sharedModule.log log: typeof sharedModule.log
} }
@@ -63,6 +65,8 @@ const defaultTmuxDeps: TmuxUtilDeps = {
getCurrentPaneId: defaultGetCurrentPaneId, getCurrentPaneId: defaultGetCurrentPaneId,
queryWindowState: defaultQueryWindowState, queryWindowState: defaultQueryWindowState,
waitForSessionReady, waitForSessionReady,
executeActions,
executeAction,
log: sharedModule.log, log: sharedModule.log,
} }
@@ -281,7 +285,7 @@ export class TmuxSessionManager {
} }
try { try {
const result = await executeAction( const result = await this.deps.executeAction(
{ type: "close", paneId: isolatedContainerPaneId, sessionId: tracked.sessionId }, { type: "close", paneId: isolatedContainerPaneId, sessionId: tracked.sessionId },
{ {
config: this.tmuxConfig, config: this.tmuxConfig,
@@ -381,7 +385,7 @@ export class TmuxSessionManager {
const { tracked, state } = args const { tracked, state } = args
try { try {
const result = await executeAction( const result = await this.deps.executeAction(
{ type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId }, { type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId },
{ {
config: this.tmuxConfig, config: this.tmuxConfig,
@@ -812,7 +816,7 @@ export class TmuxSessionManager {
return return
} }
const result = await executeActions( const result = await this.deps.executeActions(
decision.actions, decision.actions,
{ {
config: this.tmuxConfig, config: this.tmuxConfig,
@@ -872,7 +876,7 @@ export class TmuxSessionManager {
this.enqueueDeferredSession(sessionId, title) this.enqueueDeferredSession(sessionId, title)
if (result.spawnedPaneId) { if (result.spawnedPaneId) {
await executeAction( await this.deps.executeAction(
{ type: "close", paneId: result.spawnedPaneId, sessionId }, { type: "close", paneId: result.spawnedPaneId, sessionId },
{ {
config: this.tmuxConfig, config: this.tmuxConfig,
@@ -1045,7 +1049,7 @@ export class TmuxSessionManager {
return return
} }
const result = await executeActions(decision.actions, { const result = await this.deps.executeActions(decision.actions, {
config: this.tmuxConfig, config: this.tmuxConfig,
directory: this.projectDirectory, directory: this.projectDirectory,
serverUrl: this.serverUrl, serverUrl: this.serverUrl,
@@ -1185,7 +1189,7 @@ export class TmuxSessionManager {
closeAction.type === "close" && closeAction.paneId === tracked.paneId closeAction.type === "close" && closeAction.paneId === tracked.paneId
try { try {
const result = await executeAction(closeAction, { const result = await this.deps.executeAction(closeAction, {
config: this.tmuxConfig, config: this.tmuxConfig,
directory: this.projectDirectory, directory: this.projectDirectory,
serverUrl: this.serverUrl, serverUrl: this.serverUrl,
@@ -65,6 +65,10 @@ const mockTmuxDeps: TmuxUtilDeps = {
isInsideTmux: mockIsInsideTmux, isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId, getCurrentPaneId: mockGetCurrentPaneId,
queryWindowState: mockQueryWindowState, queryWindowState: mockQueryWindowState,
waitForSessionReady: async () => true,
executeActions: mockExecuteActions,
executeAction: mockExecuteAction,
log: () => {},
} }
function createConfig(): TmuxConfig { function createConfig(): TmuxConfig {
+51 -48
View File
@@ -74,6 +74,16 @@ async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms)) await new Promise((resolve) => setTimeout(resolve, ms))
} }
async function waitUntil(predicate: () => boolean, timeoutMs: number = 500): Promise<void> {
const startedAt = Date.now()
while (!predicate()) {
if (Date.now() - startedAt >= timeoutMs) {
return
}
await wait(5)
}
}
function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType<typeof createEventHandler> { function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType<typeof createEventHandler> {
return createEventHandler({ return createEventHandler({
ctx: asEventHandlerContext({}), ctx: asEventHandlerContext({}),
@@ -210,60 +220,44 @@ describe("createEventHandler - idle deduplication", () => {
})) }))
let waitForSessionReadyCallCount = 0 let waitForSessionReadyCallCount = 0
mock.module("../features/tmux-subagent/pane-state-querier", () => ({ const executeActions = mock(async (actions: Array<{ type: string; sessionId: string }>) => {
queryWindowState: async () => ({ for (const action of actions) {
windowWidth: 220, if (action.type === "spawn") {
windowHeight: 44, await spawnTmuxPane(action.sessionId)
mainPane: {
paneId: "%0",
width: 110,
height: 44,
left: 0,
top: 0,
title: "main",
isActive: true,
},
agentPanes: [],
}),
}))
mock.module("../features/tmux-subagent/action-executor", () => ({
executeActions: async (actions: Array<{ type: string; sessionId: string }>) => {
for (const action of actions) {
if (action.type === "spawn") {
await spawnTmuxPane(action.sessionId)
}
} }
}
return { return {
success: true, success: true,
spawnedPaneId: "%mock", spawnedPaneId: "%mock",
results: [], results: [],
} }
})
const executeAction = mock(async () => ({ success: true }))
const queryWindowState = mock(async () => ({
windowWidth: 220,
windowHeight: 44,
mainPane: {
paneId: "%0",
width: 110,
height: 44,
left: 0,
top: 0,
title: "main",
isActive: true,
}, },
executeAction: async () => ({ success: true }), agentPanes: [],
})) }))
mock.module("../features/tmux-subagent/session-ready-waiter", () => ({ const waitForSessionReady = mock(async () => {
waitForSessionReady: async () => { waitForSessionReadyCallCount += 1
waitForSessionReadyCallCount += 1 if (waitForSessionReadyCallCount === 1) {
if (waitForSessionReadyCallCount === 1) { throw new Error("session readiness timed out")
throw new Error("session readiness timed out") }
}
return true return true
}, })
}))
mock.module("../shared/tmux", () => ({
isInsideTmux: () => true,
getCurrentPaneId: () => "%0",
POLL_INTERVAL_BACKGROUND_MS: 100,
spawnTmuxWindow: async () => ({ success: true, paneId: "%isolated-window" }),
spawnTmuxSession: async () => ({ success: true, paneId: "%isolated-session" }),
killTmuxSessionIfExists: async () => true,
getIsolatedSessionName: (pid: number = 12345) => `omo-agents-${pid}`,
sweepStaleOmoAgentSessions: async () => 0,
}))
const { TmuxSessionManager } = await import(`../features/tmux-subagent/manager?test=${crypto.randomUUID()}`) const { TmuxSessionManager } = await import(`../features/tmux-subagent/manager?test=${crypto.randomUUID()}`)
const managerContext = asPluginInput({ const managerContext = asPluginInput({
serverUrl: new URL("http://localhost:4096"), serverUrl: new URL("http://localhost:4096"),
directory: "/tmp", directory: "/tmp",
@@ -284,6 +278,14 @@ describe("createEventHandler - idle deduplication", () => {
main_pane_size: 60, main_pane_size: 60,
main_pane_min_width: 80, main_pane_min_width: 80,
agent_pane_min_width: 40, agent_pane_min_width: 40,
}, {
isInsideTmux: () => true,
getCurrentPaneId: () => "%0",
queryWindowState,
waitForSessionReady,
executeActions,
executeAction,
log: () => {},
}) })
const eventHandler = createEventHandler({ const eventHandler = createEventHandler({
ctx: asEventHandlerContext({ ctx: asEventHandlerContext({
@@ -334,6 +336,7 @@ describe("createEventHandler - idle deduplication", () => {
}, },
})) }))
await flushMicrotasks(20) await flushMicrotasks(20)
await waitUntil(() => spawnTmuxPane.mock.calls.length === 1)
//#then //#then
expect(spawnTmuxPane).toHaveBeenCalledTimes(1) expect(spawnTmuxPane).toHaveBeenCalledTimes(1)
+11 -14
View File
@@ -4,10 +4,6 @@ import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner" import type { TmuxCommandResult } from "../runner"
const paneReplaceSpecifier = import.meta.resolve("./pane-replace") const paneReplaceSpecifier = import.meta.resolve("./pane-replace")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const enabledTmuxConfig = { const enabledTmuxConfig = {
enabled: true, enabled: true,
@@ -67,17 +63,18 @@ async function loadReplaceTmuxPane(): Promise<typeof import("./pane-replace").re
return module.replaceTmuxPane return module.replaceTmuxPane
} }
function registerModuleMocks(): void { function createDeps(): NonNullable<Parameters<typeof import("./pane-replace").replaceTmuxPane>[6]> {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock })) return {
mock.module(loggerSpecifier, () => ({ log: logMock })) log: logMock,
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) runTmuxCommand: runTmuxCommandMock,
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) isInsideTmux: isInsideTmuxMock,
getTmuxPath: getTmuxPathMock,
}
} }
describe("replaceTmuxPane runner integration", () => { describe("replaceTmuxPane runner integration", () => {
beforeEach(() => { beforeEach(() => {
mock.restore() mock.restore()
registerModuleMocks()
runTmuxCommandMock.mockClear() runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear() isInsideTmuxMock.mockClear()
getTmuxPathMock.mockClear() getTmuxPathMock.mockClear()
@@ -105,7 +102,7 @@ describe("replaceTmuxPane runner integration", () => {
const directory = "/tmp/omo-project/(replace)" const directory = "/tmp/omo-project/(replace)"
// when // when
const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory) const result = await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, createDeps())
// then // then
const sendKeysCall = getRunTmuxCommandCall(0) const sendKeysCall = getRunTmuxCommandCall(0)
@@ -123,7 +120,7 @@ describe("replaceTmuxPane runner integration", () => {
const replaceTmuxPane = await loadReplaceTmuxPane() const replaceTmuxPane = await loadReplaceTmuxPane()
// when // when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here") await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps())
// then // then
expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'") expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'")
@@ -134,7 +131,7 @@ describe("replaceTmuxPane runner integration", () => {
const replaceTmuxPane = await loadReplaceTmuxPane() const replaceTmuxPane = await loadReplaceTmuxPane()
// when // when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "") await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps())
// then // then
expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`) expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`)
@@ -145,7 +142,7 @@ describe("replaceTmuxPane runner integration", () => {
const replaceTmuxPane = await loadReplaceTmuxPane() const replaceTmuxPane = await loadReplaceTmuxPane()
// when // when
await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote") await replaceTmuxPane("%42", "session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps())
// then // then
expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'") expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'")
+28 -6
View File
@@ -1,9 +1,32 @@
import type { TmuxConfig } from "../../../config/schema" import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types" import type { SpawnPaneResult } from "../types"
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
import { isInsideTmux } from "./environment" import { isInsideTmux } from "./environment"
import { shellSingleQuote } from "../../shell-env" import { shellSingleQuote } from "../../shell-env"
type ReplaceTmuxPaneDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
getTmuxPath: typeof getTmuxPath
}
async function resolveReplaceTmuxPaneDeps(deps?: Partial<ReplaceTmuxPaneDeps>): Promise<ReplaceTmuxPaneDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
getTmuxPath,
...deps,
}
}
export async function replaceTmuxPane( export async function replaceTmuxPane(
paneId: string, paneId: string,
sessionId: string, sessionId: string,
@@ -11,22 +34,21 @@ export async function replaceTmuxPane(
config: TmuxConfig, config: TmuxConfig,
serverUrl: string, serverUrl: string,
directory: string, directory: string,
depsInput?: Partial<ReplaceTmuxPaneDeps>,
): Promise<SpawnPaneResult> { ): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([ const deps = await resolveReplaceTmuxPaneDeps(depsInput)
import("../../logger"), const { log, runTmuxCommand } = deps
import("../runner"),
])
log("[replaceTmuxPane] called", { paneId, sessionId, description }) log("[replaceTmuxPane] called", { paneId, sessionId, description })
if (!config.enabled) { if (!config.enabled) {
return { success: false } return { success: false }
} }
if (!isInsideTmux()) { if (!deps.isInsideTmux()) {
return { success: false } return { success: false }
} }
const tmux = await getTmuxPath() const tmux = await deps.getTmuxPath()
if (!tmux) { if (!tmux) {
return { success: false } return { success: false }
} }