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,
queryWindowState: mockQueryWindowState,
waitForSessionReady: mockWaitForSessionReady,
executeActions: mockExecuteActions,
executeAction: mockExecuteAction,
log: (...args) => sharedModule.log(...args),
}
+10 -6
View File
@@ -55,6 +55,8 @@ export interface TmuxUtilDeps {
getCurrentPaneId: () => string | undefined
queryWindowState: (paneId: string) => Promise<WindowState | null>
waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise<boolean>
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,
@@ -65,6 +65,10 @@ const mockTmuxDeps: TmuxUtilDeps = {
isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId,
queryWindowState: mockQueryWindowState,
waitForSessionReady: async () => true,
executeActions: mockExecuteActions,
executeAction: mockExecuteAction,
log: () => {},
}
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))
}
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> {
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)
+11 -14
View File
@@ -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<typeof import("./pane-replace").re
return module.replaceTmuxPane
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
function createDeps(): NonNullable<Parameters<typeof import("./pane-replace").replaceTmuxPane>[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'")
+28 -6
View File
@@ -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<ReplaceTmuxPaneDeps>): Promise<ReplaceTmuxPaneDeps> {
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<ReplaceTmuxPaneDeps>,
): Promise<SpawnPaneResult> {
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 }
}