From 8dcbccf063ca523282c5016882a78050a663485d Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 17:54:43 +0900 Subject: [PATCH] fix(tmux): inject pane action dependencies --- src/features/tmux-subagent/manager.test.ts | 2 + src/features/tmux-subagent/manager.ts | 16 +-- .../tmux-subagent/zombie-pane.test.ts | 4 + src/plugin/event.test.ts | 99 ++++++++++--------- .../tmux/tmux-utils/pane-replace.test.ts | 25 +++-- src/shared/tmux/tmux-utils/pane-replace.ts | 34 +++++-- 6 files changed, 106 insertions(+), 74 deletions(-) diff --git a/src/features/tmux-subagent/manager.test.ts b/src/features/tmux-subagent/manager.test.ts index 03961d70e..5f8742742 100644 --- a/src/features/tmux-subagent/manager.test.ts +++ b/src/features/tmux-subagent/manager.test.ts @@ -98,6 +98,8 @@ const mockTmuxDeps: TmuxUtilDeps = { getCurrentPaneId: mockGetCurrentPaneId, queryWindowState: mockQueryWindowState, waitForSessionReady: mockWaitForSessionReady, + executeActions: mockExecuteActions, + executeAction: mockExecuteAction, log: (...args) => sharedModule.log(...args), } diff --git a/src/features/tmux-subagent/manager.ts b/src/features/tmux-subagent/manager.ts index dda4c0468..e97273e70 100644 --- a/src/features/tmux-subagent/manager.ts +++ b/src/features/tmux-subagent/manager.ts @@ -55,6 +55,8 @@ export interface TmuxUtilDeps { getCurrentPaneId: () => string | undefined queryWindowState: (paneId: string) => Promise waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise + executeActions: typeof executeActions + executeAction: typeof executeAction log: typeof sharedModule.log } @@ -63,6 +65,8 @@ const defaultTmuxDeps: TmuxUtilDeps = { getCurrentPaneId: defaultGetCurrentPaneId, queryWindowState: defaultQueryWindowState, waitForSessionReady, + executeActions, + executeAction, log: sharedModule.log, } @@ -281,7 +285,7 @@ export class TmuxSessionManager { } try { - const result = await executeAction( + const result = await this.deps.executeAction( { type: "close", paneId: isolatedContainerPaneId, sessionId: tracked.sessionId }, { config: this.tmuxConfig, @@ -381,7 +385,7 @@ export class TmuxSessionManager { const { tracked, state } = args try { - const result = await executeAction( + const result = await this.deps.executeAction( { type: "close", paneId: tracked.paneId, sessionId: tracked.sessionId }, { config: this.tmuxConfig, @@ -812,7 +816,7 @@ export class TmuxSessionManager { return } - const result = await executeActions( + const result = await this.deps.executeActions( decision.actions, { config: this.tmuxConfig, @@ -872,7 +876,7 @@ export class TmuxSessionManager { this.enqueueDeferredSession(sessionId, title) if (result.spawnedPaneId) { - await executeAction( + await this.deps.executeAction( { type: "close", paneId: result.spawnedPaneId, sessionId }, { config: this.tmuxConfig, @@ -1045,7 +1049,7 @@ export class TmuxSessionManager { return } - const result = await executeActions(decision.actions, { + const result = await this.deps.executeActions(decision.actions, { config: this.tmuxConfig, directory: this.projectDirectory, serverUrl: this.serverUrl, @@ -1185,7 +1189,7 @@ export class TmuxSessionManager { closeAction.type === "close" && closeAction.paneId === tracked.paneId try { - const result = await executeAction(closeAction, { + const result = await this.deps.executeAction(closeAction, { config: this.tmuxConfig, directory: this.projectDirectory, serverUrl: this.serverUrl, diff --git a/src/features/tmux-subagent/zombie-pane.test.ts b/src/features/tmux-subagent/zombie-pane.test.ts index 326b48c7f..b2526ac61 100644 --- a/src/features/tmux-subagent/zombie-pane.test.ts +++ b/src/features/tmux-subagent/zombie-pane.test.ts @@ -65,6 +65,10 @@ const mockTmuxDeps: TmuxUtilDeps = { isInsideTmux: mockIsInsideTmux, getCurrentPaneId: mockGetCurrentPaneId, queryWindowState: mockQueryWindowState, + waitForSessionReady: async () => true, + executeActions: mockExecuteActions, + executeAction: mockExecuteAction, + log: () => {}, } function createConfig(): TmuxConfig { diff --git a/src/plugin/event.test.ts b/src/plugin/event.test.ts index 6d02eb27b..a2a121898 100644 --- a/src/plugin/event.test.ts +++ b/src/plugin/event.test.ts @@ -74,6 +74,16 @@ async function wait(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)) } +async function waitUntil(predicate: () => boolean, timeoutMs: number = 500): Promise { + const startedAt = Date.now() + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) { + return + } + await wait(5) + } +} + function createIdleTrackingEventHandler(dispatchCalls: EventInput[]): ReturnType { return createEventHandler({ ctx: asEventHandlerContext({}), @@ -210,60 +220,44 @@ describe("createEventHandler - idle deduplication", () => { })) let waitForSessionReadyCallCount = 0 - mock.module("../features/tmux-subagent/pane-state-querier", () => ({ - queryWindowState: async () => ({ - windowWidth: 220, - windowHeight: 44, - 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) - } + const executeActions = mock(async (actions: Array<{ type: string; sessionId: string }>) => { + for (const action of actions) { + if (action.type === "spawn") { + await spawnTmuxPane(action.sessionId) } + } - return { - success: true, - spawnedPaneId: "%mock", - results: [], - } + return { + success: true, + spawnedPaneId: "%mock", + 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", () => ({ - waitForSessionReady: async () => { - waitForSessionReadyCallCount += 1 - if (waitForSessionReadyCallCount === 1) { - throw new Error("session readiness timed out") - } + const waitForSessionReady = mock(async () => { + waitForSessionReadyCallCount += 1 + if (waitForSessionReadyCallCount === 1) { + throw new Error("session readiness timed out") + } - 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, - })) + return true + }) - 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({ serverUrl: new URL("http://localhost:4096"), directory: "/tmp", @@ -284,6 +278,14 @@ describe("createEventHandler - idle deduplication", () => { main_pane_size: 60, main_pane_min_width: 80, agent_pane_min_width: 40, + }, { + isInsideTmux: () => true, + getCurrentPaneId: () => "%0", + queryWindowState, + waitForSessionReady, + executeActions, + executeAction, + log: () => {}, }) const eventHandler = createEventHandler({ ctx: asEventHandlerContext({ @@ -334,6 +336,7 @@ describe("createEventHandler - idle deduplication", () => { }, })) await flushMicrotasks(20) + await waitUntil(() => spawnTmuxPane.mock.calls.length === 1) //#then expect(spawnTmuxPane).toHaveBeenCalledTimes(1) diff --git a/src/shared/tmux/tmux-utils/pane-replace.test.ts b/src/shared/tmux/tmux-utils/pane-replace.test.ts index 0f7f73eb8..57416a745 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.test.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.test.ts @@ -4,10 +4,6 @@ import type { TmuxConfig } from "../../../config/schema" import type { TmuxCommandResult } from "../runner" 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 = { enabled: true, @@ -67,17 +63,18 @@ async function loadReplaceTmuxPane(): Promise ({ isInsideTmux: isInsideTmuxMock })) - mock.module(loggerSpecifier, () => ({ log: logMock })) - mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock })) - mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock })) +function createDeps(): NonNullable[6]> { + return { + log: logMock, + runTmuxCommand: runTmuxCommandMock, + isInsideTmux: isInsideTmuxMock, + getTmuxPath: getTmuxPathMock, + } } describe("replaceTmuxPane runner integration", () => { beforeEach(() => { mock.restore() - registerModuleMocks() runTmuxCommandMock.mockClear() isInsideTmuxMock.mockClear() getTmuxPathMock.mockClear() @@ -105,7 +102,7 @@ describe("replaceTmuxPane runner integration", () => { const directory = "/tmp/omo-project/(replace)" // 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 const sendKeysCall = getRunTmuxCommandCall(0) @@ -123,7 +120,7 @@ describe("replaceTmuxPane runner integration", () => { const replaceTmuxPane = await loadReplaceTmuxPane() // 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 expect(getRespawnCommand()).toContain("--dir '/path with spaces/here'") @@ -134,7 +131,7 @@ describe("replaceTmuxPane runner integration", () => { const replaceTmuxPane = await loadReplaceTmuxPane() // 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 expect(getRespawnCommand()).toContain(`--dir '${process.cwd()}'`) @@ -145,7 +142,7 @@ describe("replaceTmuxPane runner integration", () => { const replaceTmuxPane = await loadReplaceTmuxPane() // 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 expect(getRespawnCommand()).toContain("--dir '/path/with'\\''quote'") diff --git a/src/shared/tmux/tmux-utils/pane-replace.ts b/src/shared/tmux/tmux-utils/pane-replace.ts index dab8d1702..623de69a5 100644 --- a/src/shared/tmux/tmux-utils/pane-replace.ts +++ b/src/shared/tmux/tmux-utils/pane-replace.ts @@ -1,9 +1,32 @@ import type { TmuxConfig } from "../../../config/schema" import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver" import type { SpawnPaneResult } from "../types" +import type { runTmuxCommand as RunTmuxCommand } from "../runner" import { isInsideTmux } from "./environment" 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): Promise { + const [{ log }, { runTmuxCommand }] = await Promise.all([ + import("../../logger"), + import("../runner"), + ]) + + return { + log, + runTmuxCommand, + isInsideTmux, + getTmuxPath, + ...deps, + } +} + export async function replaceTmuxPane( paneId: string, sessionId: string, @@ -11,22 +34,21 @@ export async function replaceTmuxPane( config: TmuxConfig, serverUrl: string, directory: string, + depsInput?: Partial, ): Promise { - const [{ log }, { runTmuxCommand }] = await Promise.all([ - import("../../logger"), - import("../runner"), - ]) + const deps = await resolveReplaceTmuxPaneDeps(depsInput) + const { log, runTmuxCommand } = deps log("[replaceTmuxPane] called", { paneId, sessionId, description }) if (!config.enabled) { return { success: false } } - if (!isInsideTmux()) { + if (!deps.isInsideTmux()) { return { success: false } } - const tmux = await getTmuxPath() + const tmux = await deps.getTmuxPath() if (!tmux) { return { success: false } }