fix(tmux): inject pane action dependencies
This commit is contained in:
@@ -98,6 +98,8 @@ const mockTmuxDeps: TmuxUtilDeps = {
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
queryWindowState: mockQueryWindowState,
|
||||
waitForSessionReady: mockWaitForSessionReady,
|
||||
executeActions: mockExecuteActions,
|
||||
executeAction: mockExecuteAction,
|
||||
log: (...args) => sharedModule.log(...args),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'")
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user