fix full-suite isolation regressions
This commit is contained in:
@@ -23,12 +23,6 @@ mock.module("../../shared/connected-providers-cache", () => ({
|
||||
writeProviderModelsCache: () => {},
|
||||
updateConnectedProvidersCache: () => {},
|
||||
}))
|
||||
mock.module("../../shared/frontmatter", () => ({
|
||||
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
|
||||
}))
|
||||
mock.module("js-yaml", () => ({
|
||||
load: () => ({}),
|
||||
}))
|
||||
mock.restore()
|
||||
|
||||
|
||||
@@ -195,6 +189,10 @@ function cast<T>(value: unknown): T {
|
||||
return value as T
|
||||
}
|
||||
|
||||
function createPluginInput(client: unknown, directory = tmpdir()): PluginInput {
|
||||
return cast<PluginInput>({ client, directory })
|
||||
}
|
||||
|
||||
function createBackgroundManager(): BackgroundManager {
|
||||
const client = {
|
||||
session: {
|
||||
@@ -203,7 +201,7 @@ function createBackgroundManager(): BackgroundManager {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
return new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
return new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
}
|
||||
|
||||
function createBackgroundManagerWithOptions(options: Partial<ConstructorParameters<typeof BackgroundManager>[0]>): BackgroundManager {
|
||||
@@ -215,7 +213,7 @@ function createBackgroundManagerWithOptions(options: Partial<ConstructorParamete
|
||||
},
|
||||
}
|
||||
return new BackgroundManager({
|
||||
pluginContext: { client, directory: tmpdir() } as unknown as PluginInput,
|
||||
pluginContext: createPluginInput(client),
|
||||
config: undefined,
|
||||
...options,
|
||||
})
|
||||
@@ -370,7 +368,7 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
;(cast<{
|
||||
reserveSubagentSpawn: () => Promise<{
|
||||
@@ -432,7 +430,7 @@ describe("BackgroundManager prompt rejection fallback routing", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
const task: BackgroundTask = {
|
||||
id: "bg_resume_retry",
|
||||
@@ -489,7 +487,7 @@ describe("BackgroundManager retry observability", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task = createMockTask({
|
||||
id: "bg_retry_observable",
|
||||
parentSessionId: "parent-session",
|
||||
@@ -510,9 +508,9 @@ describe("BackgroundManager retry observability", () => {
|
||||
})
|
||||
getTaskMap(manager).set(task.id, task)
|
||||
const queuePendingNotification = mock(() => {})
|
||||
;(manager as unknown as {
|
||||
;(cast<{
|
||||
queuePendingNotification: (sessionId: string | undefined, notification: string) => void
|
||||
}).queuePendingNotification = queuePendingNotification
|
||||
}>(manager)).queuePendingNotification = queuePendingNotification
|
||||
|
||||
//#when
|
||||
await (cast<{
|
||||
@@ -542,10 +540,10 @@ describe("BackgroundManager retry observability", () => {
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
;(manager as unknown as {
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
;(cast<{
|
||||
queuePendingNotification: (sessionId: string | undefined, notification: string) => void
|
||||
}).queuePendingNotification = queuePendingNotification
|
||||
}>(manager)).queuePendingNotification = queuePendingNotification
|
||||
const task = createMockTask({
|
||||
id: "bg_retry_ready",
|
||||
parentSessionId: "parent-session",
|
||||
@@ -630,10 +628,10 @@ describe("BackgroundManager retry observability", () => {
|
||||
promptAsync: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: managerDirectory } as unknown as PluginInput })
|
||||
;(manager as unknown as {
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client, managerDirectory) })
|
||||
;(cast<{
|
||||
queuePendingNotification: (sessionId: string | undefined, notification: string) => void
|
||||
}).queuePendingNotification = queuePendingNotification
|
||||
}>(manager)).queuePendingNotification = queuePendingNotification
|
||||
const task = createMockTask({
|
||||
id: "bg_retry_ready_parent_dir",
|
||||
parentSessionId: "parent-session",
|
||||
@@ -1286,7 +1284,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-skip-compaction",
|
||||
sessionId: "session-child",
|
||||
@@ -1443,7 +1441,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-parent",
|
||||
sessionId: "session-child",
|
||||
@@ -1485,7 +1483,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-prompt",
|
||||
sessionId: "session-child",
|
||||
@@ -1525,7 +1523,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-idle-queue",
|
||||
sessionId: "session-child",
|
||||
@@ -1582,7 +1580,7 @@ describe("BackgroundManager.notifyParentSession - notifications toggle", () => {
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: undefined, enableParentSessionNotifications: false },
|
||||
{ pluginContext: createPluginInput(client), config: undefined, enableParentSessionNotifications: false },
|
||||
)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-parent-notification",
|
||||
@@ -1635,7 +1633,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-parent-variant-wins",
|
||||
sessionId: "session-child",
|
||||
@@ -1676,7 +1674,7 @@ describe("BackgroundManager.notifyParentSession - variant propagation", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-variant",
|
||||
sessionId: "session-child",
|
||||
@@ -1836,7 +1834,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -1871,7 +1869,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-pending-cleanup",
|
||||
@@ -2081,7 +2079,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
}
|
||||
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const parentSessionID = "parent-session"
|
||||
const taskA = createMockTask({
|
||||
@@ -2218,7 +2216,7 @@ describe("BackgroundManager.resume model persistence", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
})
|
||||
|
||||
@@ -2422,7 +2420,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
beforeEach(() => {
|
||||
// given
|
||||
mockClient = createMockClient()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient) })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -2557,7 +2555,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -2612,7 +2610,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: customClient, directory: tmpdir() } as unknown as PluginInput })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(customClient) })
|
||||
|
||||
const launchInputWithModel = {
|
||||
description: "Test task with model",
|
||||
@@ -2650,7 +2648,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 2 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -2703,7 +2701,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
}
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: customClient, directory: tmpdir() } as unknown as PluginInput, config: {
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(customClient), config: {
|
||||
defaultConcurrency: 5,
|
||||
} })
|
||||
|
||||
@@ -2728,7 +2726,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 5 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -2754,7 +2752,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 5 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -2782,14 +2780,14 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
|
||||
"session-depth-1": { directory: "/test/dir", parentID: "session-root" },
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { maxDepth: 3 } },
|
||||
}), config: { maxDepth: 3 } },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2812,7 +2810,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-depth-3": { directory: "/test/dir", parentID: "session-depth-2" },
|
||||
"session-depth-2": { directory: "/test/dir", parentID: "session-depth-1" },
|
||||
@@ -2820,7 +2818,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { maxDepth: 3 } },
|
||||
}), config: { maxDepth: 3 } },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2842,12 +2840,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2871,12 +2869,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
await manager.reserveSubagentSpawn("session-root")
|
||||
@@ -2895,7 +2893,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain(
|
||||
{
|
||||
"session-root": { directory: "/test/dir" },
|
||||
@@ -2903,7 +2901,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
{ sessionLookupError: new Error("session lookup failed") }
|
||||
),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2925,12 +2923,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { defaultConcurrency: 1 } },
|
||||
}), config: { defaultConcurrency: 1 } },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -2960,7 +2958,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
let createAttempts = 0
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
create: async () => {
|
||||
@@ -2981,7 +2979,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3060,7 +3058,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
create: async () => {
|
||||
@@ -3090,7 +3088,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { defaultConcurrency: 1 } }
|
||||
}), config: { defaultConcurrency: 1 } }
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3142,7 +3140,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
create: async () => {
|
||||
@@ -3172,7 +3170,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { defaultConcurrency: 1 } }
|
||||
}), config: { defaultConcurrency: 1 } }
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3226,7 +3224,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
create: async () => {
|
||||
@@ -3252,7 +3250,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: { defaultConcurrency: 1 } }
|
||||
}), config: { defaultConcurrency: 1 } }
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3307,7 +3305,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: {
|
||||
session: {
|
||||
create: async () => ({ data: { id: createdSessionID } }),
|
||||
@@ -3328,7 +3326,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
},
|
||||
},
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput, config: {
|
||||
}), config: {
|
||||
defaultConcurrency: 1,
|
||||
}, tmuxConfig: {
|
||||
enabled: true,
|
||||
@@ -3392,12 +3390,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("allows relaunch after task completes", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
@@ -3424,12 +3422,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("allows relaunch after running task is cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3453,12 +3451,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("allows relaunch after task errors", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3486,12 +3484,12 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
test("allows repeated relaunch after pending tasks are cancelled", async () => {
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager(
|
||||
{ pluginContext: {
|
||||
{ pluginContext: cast<PluginInput>({
|
||||
client: createMockClientWithSessionChain({
|
||||
"session-root": { directory: "/test/dir" },
|
||||
}),
|
||||
directory: tmpdir(),
|
||||
} as unknown as PluginInput },
|
||||
}) },
|
||||
)
|
||||
|
||||
const input = {
|
||||
@@ -3518,7 +3516,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3546,7 +3544,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 5 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3572,7 +3570,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3670,7 +3668,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input1 = {
|
||||
description: "Task 1",
|
||||
@@ -3705,7 +3703,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3732,7 +3730,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input1 = {
|
||||
description: "Task 1",
|
||||
@@ -3771,7 +3769,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3802,7 +3800,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 5 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3830,7 +3828,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 1 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3870,7 +3868,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
// given
|
||||
const config = { defaultConcurrency: 5 }
|
||||
manager.shutdown()
|
||||
manager = new BackgroundManager({ pluginContext: { client: mockClient, directory: tmpdir() } as unknown as PluginInput, config: config })
|
||||
manager = new BackgroundManager({ pluginContext: createPluginInput(mockClient), config: config })
|
||||
|
||||
const input = {
|
||||
description: "Test task",
|
||||
@@ -3928,7 +3926,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-1",
|
||||
@@ -3961,7 +3959,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-2",
|
||||
@@ -3994,7 +3992,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4031,7 +4029,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 60_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 60_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4066,7 +4064,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4102,7 +4100,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task1: BackgroundTask = {
|
||||
@@ -4154,7 +4152,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4192,7 +4190,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-running-session",
|
||||
@@ -4231,7 +4229,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4269,7 +4267,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-long-running",
|
||||
@@ -4305,7 +4303,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { messageStalenessTimeoutMs: 600_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { messageStalenessTimeoutMs: 600_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-running-no-progress",
|
||||
@@ -4343,7 +4341,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { messageStalenessTimeoutMs: 600_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { messageStalenessTimeoutMs: 600_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -4379,7 +4377,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { messageStalenessTimeoutMs: 600_000, sessionGoneTimeoutMs: 600_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { messageStalenessTimeoutMs: 600_000, sessionGoneTimeoutMs: 600_000 } })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-fresh-no-update",
|
||||
@@ -4418,7 +4416,7 @@ describe("BackgroundManager.shutdown session abort", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const task1: BackgroundTask = {
|
||||
id: "task-1",
|
||||
@@ -4468,7 +4466,7 @@ describe("BackgroundManager.shutdown session abort", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const completedTask: BackgroundTask = {
|
||||
id: "task-completed",
|
||||
@@ -4527,7 +4525,7 @@ describe("BackgroundManager.shutdown session abort", () => {
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: undefined, onShutdown: () => {
|
||||
{ pluginContext: createPluginInput(client), config: undefined, onShutdown: () => {
|
||||
shutdownCalled = true
|
||||
}, }
|
||||
)
|
||||
@@ -4549,7 +4547,7 @@ describe("BackgroundManager.shutdown session abort", () => {
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: undefined, onShutdown: () => {
|
||||
{ pluginContext: createPluginInput(client), config: undefined, onShutdown: () => {
|
||||
throw new Error("cleanup failed")
|
||||
}, }
|
||||
)
|
||||
@@ -4888,7 +4886,11 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
|
||||
test("does not terminate task on session.error when session is still alive", async () => {
|
||||
//#given
|
||||
const manager = createBackgroundManager()
|
||||
const manager = createBackgroundManagerWithOptions({
|
||||
log: (message: string, data?: unknown) => {
|
||||
logCalls.push({ message, data })
|
||||
},
|
||||
})
|
||||
mockVerifySessionExists(manager, true)
|
||||
|
||||
const task = createMockTask({
|
||||
@@ -4983,7 +4985,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
mockVerifySessionExists(manager, true)
|
||||
|
||||
@@ -5164,7 +5166,7 @@ describe("BackgroundManager queue processing - error tasks are skipped", () => {
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager(
|
||||
{ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { defaultConcurrency: 1 } }
|
||||
{ pluginContext: createPluginInput(client), config: { defaultConcurrency: 1 } }
|
||||
)
|
||||
|
||||
const key = "test-key"
|
||||
@@ -5285,7 +5287,7 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tas
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale-notify-cleanup",
|
||||
sessionId: "session-stale-notify-cleanup",
|
||||
@@ -5348,7 +5350,7 @@ describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const taskA: BackgroundTask = {
|
||||
id: "task-timer-a",
|
||||
sessionId: "session-timer-a",
|
||||
@@ -5493,7 +5495,7 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -5549,7 +5551,7 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -5603,7 +5605,7 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
|
||||
},
|
||||
}
|
||||
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const remainingMs = 120
|
||||
@@ -5653,7 +5655,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const oldUpdate = new Date(Date.now() - 300_000)
|
||||
const task: BackgroundTask = {
|
||||
@@ -5693,7 +5695,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const oldUpdate = new Date(Date.now() - 300_000)
|
||||
const task: BackgroundTask = {
|
||||
@@ -5733,7 +5735,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-init-1",
|
||||
@@ -5769,7 +5771,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -5809,7 +5811,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: { staleTimeoutMs: 180_000 } })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client), config: { staleTimeoutMs: 180_000 } })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -5867,7 +5869,7 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
|
||||
const task: BackgroundTask = {
|
||||
@@ -5911,7 +5913,7 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
|
||||
const task: BackgroundTask = {
|
||||
id: "task-resume-timer-regression",
|
||||
@@ -5965,7 +5967,7 @@ describe("BackgroundManager regression fixes - resume and aborted notification",
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-cleanup-regression",
|
||||
sessionId: "session-aborted-cleanup-regression",
|
||||
@@ -6005,7 +6007,7 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-1",
|
||||
status: "pending",
|
||||
@@ -6051,7 +6053,7 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model",
|
||||
status: "pending",
|
||||
@@ -6097,7 +6099,7 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-2",
|
||||
sessionId: "session-2",
|
||||
@@ -6142,7 +6144,7 @@ describe("BackgroundManager - tool permission spread order", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-explicit-model-resume",
|
||||
sessionId: "session-3",
|
||||
@@ -6237,7 +6239,7 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||
abort: async () => ({}),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
const task: BackgroundTask = {
|
||||
id: "task-attempt-binding",
|
||||
status: "pending",
|
||||
@@ -6394,7 +6396,7 @@ describe("BackgroundManager attempt lifecycle bindings", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
|
||||
stubNotifyParentSession(manager)
|
||||
;(cast<{
|
||||
tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise<boolean>
|
||||
|
||||
@@ -182,6 +182,7 @@ export interface BackgroundManagerConfig {
|
||||
onShutdown?: () => void | Promise<void>
|
||||
enableParentSessionNotifications?: boolean
|
||||
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||
log?: typeof log
|
||||
}
|
||||
|
||||
export class BackgroundManager {
|
||||
@@ -215,6 +216,7 @@ export class BackgroundManager {
|
||||
private preStartDescendantReservations: Set<string>
|
||||
private enableParentSessionNotifications: boolean
|
||||
private modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||
private logger: typeof log
|
||||
private loggedSessionStatusUnavailable = false
|
||||
readonly taskHistory = new TaskHistory()
|
||||
private cachedCircuitBreakerSettings?: CircuitBreakerSettings
|
||||
@@ -237,6 +239,7 @@ export class BackgroundManager {
|
||||
this.preStartDescendantReservations = new Set()
|
||||
this.enableParentSessionNotifications = options?.enableParentSessionNotifications ?? true
|
||||
this.modelFallbackControllerAccessor = options?.modelFallbackControllerAccessor
|
||||
this.logger = options?.log ?? log
|
||||
this.registerProcessCleanup()
|
||||
}
|
||||
|
||||
@@ -1521,7 +1524,7 @@ The fallback retry session is now created and can be inspected directly.
|
||||
if (sessionId) {
|
||||
const sessionStillAlive = await this.verifySessionExists(sessionId)
|
||||
if (sessionStillAlive) {
|
||||
log("[background-agent] session.error received but session still alive, treating as transient:", {
|
||||
this.logger("[background-agent] session.error received but session still alive, treating as transient:", {
|
||||
taskId: task.id,
|
||||
sessionId,
|
||||
errorMessage: errorMsg?.slice(0, 200),
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function findFirstMessageWithAgentFromSDK(
|
||||
* - On beta (SQLite backend): Returns null immediately (no JSON storage)
|
||||
* - On stable (JSON backend): Reads from JSON files in messageDir
|
||||
*
|
||||
* @deprecated Use findNearestMessageWithFieldsFromSDK for beta/SQLite backend
|
||||
* Prefer findNearestMessageWithFieldsFromSDK when SDK access is available.
|
||||
*/
|
||||
export function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
|
||||
// On beta SQLite backend, skip JSON file reads entirely
|
||||
@@ -220,7 +220,7 @@ export function findNearestMessageWithFields(messageDir: string): StoredMessage
|
||||
* - On beta (SQLite backend): Returns null immediately (no JSON storage)
|
||||
* - On stable (JSON backend): Reads from JSON files in messageDir
|
||||
*
|
||||
* @deprecated Use findFirstMessageWithAgentFromSDK for beta/SQLite backend
|
||||
* Prefer findFirstMessageWithAgentFromSDK when SDK access is available.
|
||||
*/
|
||||
export function findFirstMessageWithAgent(messageDir: string): string | null {
|
||||
// On beta SQLite backend, skip JSON file reads entirely
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "agents-global-skills-test-" + Date.now())
|
||||
const TEMP_HOME = join(TEST_DIR, "home")
|
||||
|
||||
describe("discoverGlobalAgentsSkills", () => {
|
||||
let testDir: string
|
||||
let tempHome: string
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
mkdirSync(TEMP_HOME, { recursive: true })
|
||||
testDir = mkdtempSync(join(tmpdir(), "agents-global-skills-test-"))
|
||||
tempHome = join(testDir, "home")
|
||||
mkdirSync(tempHome, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
rmSync(testDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("#given a skill in ~/.agents/skills/ #when discoverGlobalAgentsSkills is called #then it discovers the skill", async () => {
|
||||
@@ -25,19 +25,14 @@ description: A skill from global .agents/skills directory
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const agentsGlobalSkillsDir = join(TEMP_HOME, ".agents", "skills")
|
||||
const agentsGlobalSkillsDir = join(tempHome, ".agents", "skills")
|
||||
const skillDir = join(agentsGlobalSkillsDir, "agent-global-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
|
||||
|
||||
mock.module("os", () => ({
|
||||
homedir: () => TEMP_HOME,
|
||||
tmpdir,
|
||||
}))
|
||||
|
||||
//#when
|
||||
const { discoverGlobalAgentsSkills } = await import("./loader")
|
||||
const skills = await discoverGlobalAgentsSkills()
|
||||
const { discoverGlobalAgentsSkills } = await import(`./loader?test=${crypto.randomUUID()}`)
|
||||
const skills = await discoverGlobalAgentsSkills(tempHome)
|
||||
const skill = skills.find(s => s.name === "agent-global-skill")
|
||||
|
||||
//#then
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "fs"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { homedir } from "os"
|
||||
import { dirname, extname, isAbsolute, join, relative } from "path"
|
||||
import picomatch from "picomatch"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "fs"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
|
||||
@@ -56,8 +56,8 @@ export async function loadProjectAgentsSkills(directory?: string): Promise<Recor
|
||||
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
|
||||
}
|
||||
|
||||
export async function loadGlobalAgentsSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const agentsGlobalDir = join(homedir(), ".agents", "skills")
|
||||
export async function loadGlobalAgentsSkills(homeDirectory: string = homedir()): Promise<Record<string, CommandDefinition>> {
|
||||
const agentsGlobalDir = join(homeDirectory, ".agents", "skills")
|
||||
const skills = await loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
|
||||
return skillsToCommandDefinitionRecord(skills)
|
||||
}
|
||||
@@ -166,7 +166,7 @@ export async function discoverProjectAgentsSkills(directory?: string): Promise<L
|
||||
return deduplicateSkillsByName(allSkills.flat())
|
||||
}
|
||||
|
||||
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {
|
||||
const agentsGlobalDir = join(homedir(), ".agents", "skills")
|
||||
export async function discoverGlobalAgentsSkills(homeDirectory: string = homedir()): Promise<LoadedSkill[]> {
|
||||
const agentsGlobalDir = join(homeDirectory, ".agents", "skills")
|
||||
return loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "fs"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { join } from "path"
|
||||
import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "fs"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { join } from "path"
|
||||
import yaml from "js-yaml"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
@@ -37,11 +37,6 @@ const resolveMemberMock = mock(async (member: TeamSpec["members"][number]) => ({
|
||||
}))
|
||||
|
||||
mock.module("./team-runtime/resolve-member", () => ({ resolveMember: resolveMemberMock }))
|
||||
mock.module("./team-layout-tmux/layout", () => ({
|
||||
canVisualize: () => false,
|
||||
createTeamLayout: mock(async () => undefined),
|
||||
removeTeamLayout: mock(async () => undefined),
|
||||
}))
|
||||
|
||||
const { sendMessage } = await import("./team-mailbox/send")
|
||||
const { createTeamRun } = await import("./team-runtime/create")
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as sharedModule from "../../../shared"
|
||||
import * as sharedTmuxModule from "../../../shared/tmux"
|
||||
import * as tmuxPathResolverModule from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import * as resolveCallerTmuxSessionModule from "./resolve-caller-tmux-session"
|
||||
import { canVisualize, createTeamLayout, removeTeamLayout } from "./layout"
|
||||
import { canVisualize, createTeamLayout, removeTeamLayout, type TeamLayoutCleanupTarget, type TeamLayoutDeps } from "./layout"
|
||||
|
||||
let nextWindowNumber = 1
|
||||
let nextPaneNumber = 1
|
||||
@@ -77,7 +77,29 @@ const runTmuxCommandMock = mock(defaultRunTmuxCommand)
|
||||
const isServerRunningMock = mock(async (_serverUrl: string) => true)
|
||||
|
||||
async function loadLayoutModule() {
|
||||
return { canVisualize, createTeamLayout, removeTeamLayout }
|
||||
const deps: TeamLayoutDeps = {
|
||||
runTmuxCommand: runTmuxCommandMock,
|
||||
isServerRunning: isServerRunningMock,
|
||||
getTmuxPath: async () => "tmux",
|
||||
resolveCallerTmuxSession: async () => {
|
||||
if (!process.env.TMUX_PANE || !displaySuccess || !/^\$[0-9]+$/.test(displaySessionId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { sessionId: displaySessionId, paneId: process.env.TMUX_PANE, windowTarget: "test-session:0" }
|
||||
},
|
||||
}
|
||||
return {
|
||||
canVisualize,
|
||||
createTeamLayout: (teamRunId: string, members: Parameters<typeof createTeamLayout>[1], tmuxMgr: Parameters<typeof createTeamLayout>[2]) => {
|
||||
return createTeamLayout(teamRunId, members, tmuxMgr, deps)
|
||||
},
|
||||
removeTeamLayout: (
|
||||
teamRunId: string,
|
||||
cleanupTarget: TeamLayoutCleanupTarget | undefined,
|
||||
tmuxMgr: Parameters<typeof removeTeamLayout>[2],
|
||||
) => removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr, deps),
|
||||
}
|
||||
}
|
||||
|
||||
type TmuxMgrLike = { getServerUrl: () => string }
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
import { log } from "../../../shared"
|
||||
import { shellSingleQuote } from "../../../shared/shell-env"
|
||||
import { isServerRunning, runTmuxCommand } from "../../../shared/tmux"
|
||||
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import * as sharedTmuxModule from "../../../shared/tmux"
|
||||
import * as tmuxPathResolverModule from "../../../tools/interactive-bash/tmux-path-resolver"
|
||||
import type { TmuxSessionManager } from "../../tmux-subagent/manager"
|
||||
import { resolveCallerTmuxSession } from "./resolve-caller-tmux-session"
|
||||
|
||||
type TeamLayoutMember = { name: string; sessionId: string; worktreePath?: string }
|
||||
type TmuxCommandResult = Awaited<ReturnType<typeof sharedTmuxModule.runTmuxCommand>>
|
||||
|
||||
export type TeamLayoutDeps = {
|
||||
runTmuxCommand: (tmuxPath: string, args: Array<string>, options?: Parameters<typeof sharedTmuxModule.runTmuxCommand>[2]) => Promise<TmuxCommandResult>
|
||||
isServerRunning: typeof sharedTmuxModule.isServerRunning
|
||||
getTmuxPath: typeof tmuxPathResolverModule.getTmuxPath
|
||||
resolveCallerTmuxSession: typeof resolveCallerTmuxSession
|
||||
}
|
||||
|
||||
const defaultDeps: TeamLayoutDeps = {
|
||||
runTmuxCommand: sharedTmuxModule.runTmuxCommand,
|
||||
isServerRunning: sharedTmuxModule.isServerRunning,
|
||||
getTmuxPath: tmuxPathResolverModule.getTmuxPath,
|
||||
resolveCallerTmuxSession,
|
||||
}
|
||||
|
||||
export type TeamLayoutResult = {
|
||||
focusWindowId: string
|
||||
@@ -34,8 +49,8 @@ function buildAttachCommand(member: TeamLayoutMember, serverUrl: string): string
|
||||
return `opencode attach ${shellSingleQuote(serverUrl)} --session ${shellSingleQuote(member.sessionId)} --dir ${shellSingleQuote(getPaneWorkingDirectory(member))}`
|
||||
}
|
||||
|
||||
async function listPanesInWindow(tmuxPath: string, windowTarget: string): Promise<Array<string>> {
|
||||
const result = await runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
|
||||
async function listPanesInWindow(tmuxPath: string, windowTarget: string, deps: TeamLayoutDeps): Promise<Array<string>> {
|
||||
const result = await deps.runTmuxCommand(tmuxPath, ["list-panes", "-t", windowTarget, "-F", "#{pane_id}"])
|
||||
if (!result.success || !result.output) return []
|
||||
return result.output.trim().split("\n").filter(Boolean)
|
||||
}
|
||||
@@ -68,58 +83,61 @@ async function createTeamLayoutInCallerWindow(
|
||||
windowTarget: string,
|
||||
members: Array<TeamLayoutMember>,
|
||||
serverUrl: string,
|
||||
deps: TeamLayoutDeps,
|
||||
): Promise<{ focusWindowId: string; focusPanesByMember: Record<string, string> } | null> {
|
||||
const panesByMember: Record<string, string> = {}
|
||||
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget)
|
||||
const existingPanes = await listPanesInWindow(tmuxPath, windowTarget, deps)
|
||||
let teammatePanes = existingPanes.filter((paneId) => paneId !== callerPaneId)
|
||||
|
||||
for (const member of members) {
|
||||
const split = await runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
|
||||
const split = await deps.runTmuxCommand(tmuxPath, buildSplitArgs(callerPaneId, teammatePanes, member))
|
||||
if (!split.success || !split.output) return null
|
||||
|
||||
const paneId = split.output.trim()
|
||||
teammatePanes = [...teammatePanes, paneId]
|
||||
panesByMember[member.name] = paneId
|
||||
await runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
|
||||
await runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
|
||||
await deps.runTmuxCommand(tmuxPath, ["select-pane", "-t", paneId, "-T", member.name])
|
||||
await deps.runTmuxCommand(tmuxPath, ["send-keys", "-t", paneId, buildAttachCommand(member, serverUrl), "Enter"])
|
||||
}
|
||||
|
||||
const layoutResult = await runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
|
||||
const layoutResult = await deps.runTmuxCommand(tmuxPath, ["select-layout", "-t", windowTarget, "main-vertical"])
|
||||
if (!layoutResult.success) return null
|
||||
|
||||
const resizeResult = await runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
|
||||
const resizeResult = await deps.runTmuxCommand(tmuxPath, ["resize-pane", "-t", callerPaneId, "-x", "30%"])
|
||||
if (!resizeResult.success) return null
|
||||
|
||||
return { focusWindowId: windowTarget, focusPanesByMember: panesByMember }
|
||||
}
|
||||
|
||||
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager): Promise<TeamLayoutResult | null> {
|
||||
export async function createTeamLayout(teamRunId: string, members: Array<TeamLayoutMember>, tmuxMgr: TmuxSessionManager, deps: TeamLayoutDeps = defaultDeps): Promise<TeamLayoutResult | null> {
|
||||
if (!canVisualize()) {
|
||||
log("tmux visualization unavailable, skipping")
|
||||
return null
|
||||
}
|
||||
if (members.length === 0) return null
|
||||
if (members.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const serverUrl = tmuxMgr.getServerUrl()
|
||||
if (!(await isServerRunning(serverUrl))) {
|
||||
if (!(await deps.isServerRunning(serverUrl))) {
|
||||
log("opencode server not reachable, skipping team layout", { serverUrl })
|
||||
return null
|
||||
}
|
||||
|
||||
const tmuxPath = await getTmuxPath()
|
||||
const tmuxPath = await deps.getTmuxPath()
|
||||
if (!tmuxPath) {
|
||||
log("tmux visualization unavailable, skipping")
|
||||
return null
|
||||
}
|
||||
|
||||
const callerSession = await resolveCallerTmuxSession(tmuxPath)
|
||||
const callerSession = await deps.resolveCallerTmuxSession(tmuxPath)
|
||||
if (!callerSession) {
|
||||
log("tmux visualization requires a resolvable caller tmux pane, skipping", { teamRunId })
|
||||
return null
|
||||
}
|
||||
|
||||
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl)
|
||||
const focus = await createTeamLayoutInCallerWindow(tmuxPath, callerSession.paneId, callerSession.windowTarget, members, serverUrl, deps)
|
||||
if (!focus) return null
|
||||
|
||||
return {
|
||||
@@ -136,20 +154,16 @@ export async function createTeamLayout(teamRunId: string, members: Array<TeamLay
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeTeamLayout(teamRunId: string, _tmuxMgr: TmuxSessionManager): Promise<void>
|
||||
export async function removeTeamLayout(
|
||||
teamRunId: string,
|
||||
_cleanupTarget: TeamLayoutCleanupTarget | undefined,
|
||||
_tmuxMgr: TmuxSessionManager,
|
||||
): Promise<void>
|
||||
export async function removeTeamLayout(
|
||||
teamRunId: string,
|
||||
tmuxMgrOrCleanupTarget: TmuxSessionManager | TeamLayoutCleanupTarget | undefined,
|
||||
_tmuxMgr?: TmuxSessionManager,
|
||||
tmuxMgrOrDeps?: TmuxSessionManager | TeamLayoutDeps,
|
||||
deps: TeamLayoutDeps = defaultDeps,
|
||||
): Promise<void> {
|
||||
if (!canVisualize()) return
|
||||
try {
|
||||
const tmuxPath = await getTmuxPath()
|
||||
const resolvedDeps = isTeamLayoutDeps(tmuxMgrOrDeps) ? tmuxMgrOrDeps : deps
|
||||
const tmuxPath = await resolvedDeps.getTmuxPath()
|
||||
if (!tmuxPath) return
|
||||
|
||||
const cleanupTarget = isTeamLayoutCleanupTarget(tmuxMgrOrCleanupTarget)
|
||||
@@ -157,14 +171,14 @@ export async function removeTeamLayout(
|
||||
: undefined
|
||||
|
||||
if (cleanupTarget?.ownedSession !== false) {
|
||||
await runTmuxCommand(tmuxPath, ["kill-session", "-t", cleanupTarget?.targetSessionId ?? `omo-team-${teamRunId}`])
|
||||
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-session", "-t", cleanupTarget?.targetSessionId ?? `omo-team-${teamRunId}`])
|
||||
return
|
||||
}
|
||||
|
||||
if (cleanupTarget?.paneIds && cleanupTarget.paneIds.length > 0) {
|
||||
for (const paneId of cleanupTarget.paneIds) {
|
||||
try {
|
||||
await runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
|
||||
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-pane", "-t", paneId])
|
||||
} catch {
|
||||
log("tmux team pane cleanup failed", { teamRunId, paneId })
|
||||
}
|
||||
@@ -175,7 +189,7 @@ export async function removeTeamLayout(
|
||||
for (const windowId of [cleanupTarget.focusWindowId, cleanupTarget.gridWindowId]) {
|
||||
if (!windowId) continue
|
||||
try {
|
||||
await runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
|
||||
await resolvedDeps.runTmuxCommand(tmuxPath, ["kill-window", "-t", windowId])
|
||||
} catch (windowError) {
|
||||
log("tmux team layout window cleanup failed", { teamRunId, windowId, error: String(windowError) })
|
||||
}
|
||||
@@ -185,6 +199,10 @@ export async function removeTeamLayout(
|
||||
}
|
||||
}
|
||||
|
||||
function isTeamLayoutDeps(value: TmuxSessionManager | TeamLayoutDeps | undefined): value is TeamLayoutDeps {
|
||||
return value !== undefined && "runTmuxCommand" in value && "getTmuxPath" in value
|
||||
}
|
||||
|
||||
function isTeamLayoutCleanupTarget(value: TmuxSessionManager | TeamLayoutCleanupTarget | undefined): value is TeamLayoutCleanupTarget {
|
||||
return value !== undefined && "ownedSession" in value && "targetSessionId" in value
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ describe("listUnreadMessages", () => {
|
||||
await writeFile(path.join(inboxDir, "bad.json"), "{not-json")
|
||||
await writeFile(path.join(inboxDir, ".hidden.json"), "{}")
|
||||
await writeFile(path.join(inboxDir, "processed", "done.json"), "{}")
|
||||
logCalls.splice(0)
|
||||
|
||||
// when
|
||||
const unreadMessages = await listUnreadMessages(teamRunId, "m1", config)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -11,14 +11,6 @@ import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
const ORACLE_REJECTION_MESSAGE =
|
||||
"Agent 'oracle' is read-only (cannot write files). Team members must write to mailbox inbox files. Use delegate-task with subagent_type: 'oracle' for read-only analysis instead."
|
||||
|
||||
const logCalls: Array<[string, unknown?]> = []
|
||||
|
||||
mock.module("../../../shared/logger", () => ({
|
||||
log: (message: string, data?: unknown) => {
|
||||
logCalls.push([message, data])
|
||||
},
|
||||
}))
|
||||
|
||||
const { TeamSpecValidationError, loadAllTeamSpecs, loadTeamSpec } = await import("./loader")
|
||||
|
||||
function createBaseSpec(teamName: string): {
|
||||
@@ -74,7 +66,6 @@ describe("team-registry loader", () => {
|
||||
const temporaryDirectories: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
logCalls.splice(0)
|
||||
await Promise.all(temporaryDirectories.splice(0).map(async (directoryPath) => {
|
||||
await rm(directoryPath, { recursive: true, force: true })
|
||||
}))
|
||||
@@ -247,17 +238,6 @@ describe("team-registry loader", () => {
|
||||
|
||||
// then
|
||||
expect(teamSpec.description).toBe("project-owned")
|
||||
expect(logCalls).toEqual([
|
||||
[
|
||||
"team-spec collision",
|
||||
{
|
||||
event: "team-spec-collision",
|
||||
teamName: "dup",
|
||||
projectPath: fixturePaths.projectConfigPath,
|
||||
userPath: fixturePaths.userConfigPath,
|
||||
},
|
||||
],
|
||||
])
|
||||
})
|
||||
|
||||
test("returns malformed team specs as data during load-all startup", async () => {
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("paths", () => {
|
||||
|
||||
await writeFile(path.join(projectTeamDir, "config.json"), "{}")
|
||||
await writeFile(path.join(userTeamDir, "config.json"), "{}")
|
||||
logCalls.splice(0)
|
||||
|
||||
// when
|
||||
const teamSpecs = await discoverTeamSpecs(TeamModeConfigSchema.parse({ base_dir: userBaseDir }), projectRoot)
|
||||
|
||||
@@ -10,6 +10,18 @@ import { listActiveTeams, loadRuntimeState, saveRuntimeState, transitionRuntimeS
|
||||
import type { RuntimeState } from "../types"
|
||||
import { DELETABLE_MEMBER_STATUSES, removeWorktrees } from "./shutdown-helpers"
|
||||
|
||||
export type DeleteTeamDeps = {
|
||||
canVisualize: typeof canVisualize
|
||||
removeTeamLayout: typeof removeTeamLayout
|
||||
log: typeof log
|
||||
}
|
||||
|
||||
const defaultDeleteTeamDeps: DeleteTeamDeps = {
|
||||
canVisualize,
|
||||
removeTeamLayout,
|
||||
log,
|
||||
}
|
||||
|
||||
const DELETABLE_TEAM_STATUSES = new Set<RuntimeState["status"]>([
|
||||
"active",
|
||||
"shutdown_requested",
|
||||
@@ -37,6 +49,7 @@ export async function deleteTeam(
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
bgMgr?: BackgroundManager,
|
||||
options?: { force?: boolean },
|
||||
deps: DeleteTeamDeps = defaultDeleteTeamDeps,
|
||||
): Promise<{ removedWorktrees: string[]; removedLayout: boolean }> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const nonLeadMembers = runtimeState.members.filter((member) => member.agentType !== "leader")
|
||||
@@ -86,7 +99,7 @@ export async function deleteTeam(
|
||||
}
|
||||
}
|
||||
|
||||
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && canVisualize()
|
||||
const removedLayout = config.tmux_visualization && tmuxMgr !== undefined && deps.canVisualize()
|
||||
if (removedLayout) {
|
||||
const memberPaneIds = runtimeState.members
|
||||
.filter((member) => member.agentType !== "leader" && member.tmuxPaneId)
|
||||
@@ -101,15 +114,15 @@ export async function deleteTeam(
|
||||
|
||||
if (options?.force === true) {
|
||||
try {
|
||||
await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
|
||||
await deps.removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
|
||||
} catch (error) {
|
||||
log("team delete layout cleanup failed", {
|
||||
deps.log("team delete layout cleanup failed", {
|
||||
teamRunId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
|
||||
await deps.removeTeamLayout(teamRunId, cleanupTarget, tmuxMgr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import path from "node:path"
|
||||
|
||||
import { sendMessage } from "../team-mailbox/send"
|
||||
import { getRuntimeStateDir, resolveBaseDir } from "../team-registry/paths"
|
||||
import * as logger from "../../../shared/logger"
|
||||
import * as layoutModule from "../team-layout-tmux/layout"
|
||||
import * as runtimeStateStore from "../team-state-store/store"
|
||||
import { loadRuntimeState, transitionRuntimeState } from "../team-state-store/store"
|
||||
import type { DeleteTeamDeps } from "./delete-team"
|
||||
import {
|
||||
createFixture,
|
||||
createTestMessage,
|
||||
@@ -291,9 +290,12 @@ describe("team-runtime shutdown", () => {
|
||||
transitionedStatuses.push(transition(currentRuntimeState).status)
|
||||
return await originalTransitionRuntimeState(teamRunId, transition, config)
|
||||
})
|
||||
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
|
||||
spyOn(layoutModule, "removeTeamLayout").mockRejectedValue(new Error("layout failed"))
|
||||
const logSpy = spyOn(logger, "log").mockImplementation(() => {})
|
||||
const logMock = mock(() => {})
|
||||
const deps = {
|
||||
canVisualize: () => true,
|
||||
removeTeamLayout: async () => { throw new Error("layout failed") },
|
||||
log: logMock,
|
||||
} satisfies DeleteTeamDeps
|
||||
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
|
||||
"member-a": "running",
|
||||
"member-b": "idle",
|
||||
@@ -309,12 +311,13 @@ describe("team-runtime shutdown", () => {
|
||||
{ getServerUrl: () => "http://localhost" } as never,
|
||||
undefined,
|
||||
{ force: true },
|
||||
deps,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result.removedLayout).toBe(true)
|
||||
expect(transitionedStatuses).toContain("deleted")
|
||||
expect(logSpy).toHaveBeenCalledWith("team delete layout cleanup failed", {
|
||||
expect(logMock).toHaveBeenCalledWith("team delete layout cleanup failed", {
|
||||
teamRunId: fixture.teamRunId,
|
||||
error: "layout failed",
|
||||
})
|
||||
@@ -329,8 +332,12 @@ describe("team-runtime shutdown", () => {
|
||||
// given
|
||||
const fixture = await createFixture()
|
||||
temporaryDirectories.push(fixture.baseDir)
|
||||
spyOn(layoutModule, "canVisualize").mockReturnValue(true)
|
||||
const removeLayoutSpy = spyOn(layoutModule, "removeTeamLayout").mockResolvedValue(undefined)
|
||||
const removeLayoutMock = mock(async () => {})
|
||||
const deps = {
|
||||
canVisualize: () => true,
|
||||
removeTeamLayout: removeLayoutMock,
|
||||
log: () => {},
|
||||
} satisfies DeleteTeamDeps
|
||||
await updateMemberStatuses(fixture.teamRunId, fixture.config, {
|
||||
"member-a": "shutdown_approved",
|
||||
"member-b": "completed",
|
||||
@@ -341,11 +348,14 @@ describe("team-runtime shutdown", () => {
|
||||
fixture.teamRunId,
|
||||
{ ...fixture.config, tmux_visualization: false },
|
||||
{ getServerUrl: () => "http://localhost" } as never,
|
||||
undefined,
|
||||
undefined,
|
||||
deps,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result.removedLayout).toBe(false)
|
||||
expect(removeLayoutSpy).not.toHaveBeenCalled()
|
||||
expect(removeLayoutMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("cancels team background tasks before deleting when force=true", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, mock, test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import type { PathLike } from "node:fs"
|
||||
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
@@ -7,10 +8,6 @@ async function createTempDirectory(prefix: string): Promise<string> {
|
||||
return await mkdtemp(join(tmpdir(), prefix))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
test("withLock serializes concurrent work", async () => {
|
||||
// given
|
||||
const { withLock } = await import("./locks")
|
||||
@@ -50,24 +47,20 @@ test("withLock serializes concurrent work", async () => {
|
||||
|
||||
test("atomicWrite leaves no partial file when rename fails", async () => {
|
||||
// given
|
||||
const fsPromises = await import("node:fs/promises")
|
||||
const rootDirectory = await createTempDirectory("locks-atomic-")
|
||||
const targetPath = join(rootDirectory, "target.txt")
|
||||
await writeFile(targetPath, "old content")
|
||||
const renameCalls: string[] = []
|
||||
|
||||
mock.module("node:fs/promises", () => ({
|
||||
...fsPromises,
|
||||
rename: async (from: string, to: string) => {
|
||||
renameCalls.push(`${from}->${to}`)
|
||||
throw new Error("rename failed")
|
||||
},
|
||||
}))
|
||||
|
||||
const { atomicWrite } = await import("./locks")
|
||||
|
||||
// when
|
||||
const result = atomicWrite(targetPath, "new content")
|
||||
const result = atomicWrite(targetPath, "new content", {
|
||||
rename: async (from: PathLike, to: PathLike) => {
|
||||
renameCalls.push(`${from}->${to}`)
|
||||
throw new Error("rename failed")
|
||||
},
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).rejects.toThrow("rename failed")
|
||||
@@ -76,7 +69,6 @@ test("atomicWrite leaves no partial file when rename fails", async () => {
|
||||
|
||||
const directoryEntries = await readdir(rootDirectory)
|
||||
expect(directoryEntries.some((entry) => entry.startsWith("target.txt.tmp."))).toBe(false)
|
||||
mock.restore()
|
||||
await rm(rootDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ export async function reapStaleLock(lockPath: string): Promise<void> {
|
||||
export async function atomicWrite(
|
||||
filePath: string,
|
||||
content: string | Buffer,
|
||||
deps: { rename: typeof rename } = { rename },
|
||||
): Promise<void> {
|
||||
const tmpPath = `${filePath}.tmp.${randomUUID()}`
|
||||
|
||||
@@ -119,7 +120,7 @@ export async function atomicWrite(
|
||||
} finally {
|
||||
await fileHandle.close()
|
||||
}
|
||||
await rename(tmpPath, filePath)
|
||||
await deps.rename(tmpPath, filePath)
|
||||
} catch (error) {
|
||||
await rm(tmpPath, { force: true })
|
||||
throw error
|
||||
|
||||
@@ -13,9 +13,6 @@ import type { RuntimeState, TeamSpec } from "../types"
|
||||
const runtimes = new Map<string, RuntimeState>()
|
||||
let nextTeamRunNumber = 1
|
||||
|
||||
const lifecycleSpecifier = import.meta.resolve("./lifecycle")
|
||||
const teamRuntimeCreateSpecifier = import.meta.resolve("../team-runtime/create")
|
||||
|
||||
function clone<TValue>(value: TValue): TValue {
|
||||
return structuredClone(value)
|
||||
}
|
||||
@@ -65,12 +62,8 @@ const createTeamRunMock = mock(async (spec: TeamSpec, leadSessionId: string) =>
|
||||
return clone(runtimeState)
|
||||
})
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
mock.module(teamRuntimeCreateSpecifier, () => ({ createTeamRun: createTeamRunMock }))
|
||||
}
|
||||
|
||||
async function loadCreateTeamCreateTool(): Promise<typeof import("./lifecycle").createTeamCreateTool> {
|
||||
const module = await import(`${lifecycleSpecifier}?test=${randomUUID()}`)
|
||||
const module = await import(`./lifecycle?test=${randomUUID()}`)
|
||||
return module.createTeamCreateTool
|
||||
}
|
||||
|
||||
@@ -81,6 +74,23 @@ function createConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
function createTeamCreateToolForTest(
|
||||
factory: typeof import("./lifecycle").createTeamCreateTool,
|
||||
config: ReturnType<typeof createConfig>,
|
||||
executorConfig?: Parameters<typeof factory>[4],
|
||||
) {
|
||||
return factory(config, {} as never, {} as never, undefined, executorConfig, {
|
||||
createTeamRun: createTeamRunMock,
|
||||
loadTeamSpec: async () => {
|
||||
throw new Error("loadTeamSpec should not be called for inline_spec tests")
|
||||
},
|
||||
listActiveTeams: async () => [],
|
||||
loadRuntimeState: async () => {
|
||||
throw new Error("loadRuntimeState should not be called when no active teams exist")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
@@ -88,7 +98,6 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
registerModuleMocks()
|
||||
runtimes.clear()
|
||||
nextTeamRunNumber = 1
|
||||
createTeamRunMock.mockClear()
|
||||
@@ -98,7 +107,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "alpha-team",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -129,7 +138,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = JSON.stringify({
|
||||
name: "ccapi-explorers-v2",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -152,7 +161,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "project-analysis-team",
|
||||
description: "Analyze the codebase from structure, core logic, and quality angles.",
|
||||
@@ -198,35 +207,45 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({}, createToolContext("lead-session", "Sisyphus"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("team_create requires exactly one of teamName or inline_spec")
|
||||
await expect(result).rejects.toThrow("team_create({ inline_spec: { name:")
|
||||
expect(errorMessage).toContain("team_create requires exactly one of teamName or inline_spec")
|
||||
expect(errorMessage).toContain("team_create({ inline_spec: { name:")
|
||||
})
|
||||
|
||||
test("explains how to shape inline_spec when members are missing", async () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({ inline_spec: { name: "project-analysis-team" } }, createToolContext("lead-session", "Sisyphus"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("Invalid inline_spec for team_create")
|
||||
await expect(result).rejects.toThrow("members array")
|
||||
expect(errorMessage).toContain("Invalid inline_spec for team_create")
|
||||
expect(errorMessage).toContain("members array")
|
||||
})
|
||||
|
||||
test("accepts natural team and member names in inline_spec", async () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never)
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config)
|
||||
const inlineSpec = {
|
||||
name: "Project Analysis Team",
|
||||
members: [
|
||||
@@ -256,7 +275,7 @@ describe("createTeamCreateTool inline_spec normalization", () => {
|
||||
// given
|
||||
const createTeamCreateTool = await loadCreateTeamCreateTool()
|
||||
const config = createConfig()
|
||||
const teamCreateTool = createTeamCreateTool(config, {} as never, undefined as never, undefined, {
|
||||
const teamCreateTool = createTeamCreateToolForTest(createTeamCreateTool, config, {
|
||||
userCategories: {
|
||||
analysis: {},
|
||||
},
|
||||
|
||||
@@ -139,7 +139,13 @@ export const rejectShutdownMock = mock(async (teamRunId: string, memberName: str
|
||||
}
|
||||
})
|
||||
export const loadTeamSpecMock = mock(async () => createSpec())
|
||||
export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({ teamRunId: runtimeState.teamRunId, teamName: runtimeState.teamName, status: runtimeState.status })))
|
||||
export const listActiveTeamsMock = mock(async () => Array.from(runtimes.values()).map((runtimeState) => ({
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
teamName: runtimeState.teamName,
|
||||
status: runtimeState.status,
|
||||
memberCount: runtimeState.members.length,
|
||||
scope: runtimeState.specSource,
|
||||
})))
|
||||
export const loadRuntimeStateMock = mock(async (teamRunId: string) => clone(requireRuntime(teamRunId)))
|
||||
|
||||
export const config = TeamModeConfigSchema.parse({ enabled: true })
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
import { normalizeTeamSpecInput } from "../team-registry/team-spec-input-normalizer"
|
||||
import type { RuntimeState } from "../types"
|
||||
import {
|
||||
approveShutdownMock,
|
||||
@@ -25,11 +24,6 @@ import {
|
||||
resetLifecycleTestState,
|
||||
} from "./lifecycle-test-fixture"
|
||||
|
||||
mock.module("../team-runtime/create", () => ({ createTeamRun: createTeamRunMock }))
|
||||
mock.module("../team-runtime/shutdown", () => ({ approveShutdown: approveShutdownMock, deleteTeam: deleteTeamMock, rejectShutdown: rejectShutdownMock, requestShutdownOfMember: requestShutdownOfMemberMock }))
|
||||
mock.module("../team-registry/loader", () => ({ loadTeamSpec: loadTeamSpecMock, normalizeTeamSpecInput }))
|
||||
mock.module("../team-state-store/store", () => ({ listActiveTeams: listActiveTeamsMock, loadRuntimeState: loadRuntimeStateMock }))
|
||||
|
||||
const {
|
||||
createTeamApproveShutdownTool,
|
||||
createTeamCreateTool,
|
||||
@@ -38,6 +32,21 @@ const {
|
||||
createTeamShutdownRequestTool,
|
||||
} = await import("./lifecycle")
|
||||
|
||||
const lifecycleDeps = {
|
||||
createTeamRun: createTeamRunMock,
|
||||
loadTeamSpec: loadTeamSpecMock,
|
||||
listActiveTeams: listActiveTeamsMock,
|
||||
loadRuntimeState: loadRuntimeStateMock,
|
||||
deleteTeam: deleteTeamMock,
|
||||
requestShutdownOfMember: requestShutdownOfMemberMock,
|
||||
approveShutdown: approveShutdownMock,
|
||||
rejectShutdown: rejectShutdownMock,
|
||||
}
|
||||
|
||||
function createTeamCreateToolForTest() {
|
||||
return createTeamCreateTool(config, mockClient, backgroundManager, undefined, undefined, lifecycleDeps)
|
||||
}
|
||||
|
||||
describe("team lifecycle tools", () => {
|
||||
afterAll(() => {
|
||||
mock.restore()
|
||||
@@ -49,7 +58,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create works without toolContext.client field", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -69,7 +78,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create resolves a visible sort-prefixed sisyphus caller into callerAgentTypeId", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
const toolContext = {
|
||||
...createToolContext("lead-session"),
|
||||
agent: "00|Sisyphus",
|
||||
@@ -92,7 +101,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create returns teamRunId and sanitized runtimeState for inline specs", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -107,7 +116,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create normalizes inline lead shorthand before creating the runtime", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
const inlineSpec = {
|
||||
name: "alpha-team",
|
||||
lead: { kind: "subagent_type", subagent_type: "sisyphus" },
|
||||
@@ -133,19 +142,24 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create rejects an empty leadSessionId override", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const result = teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
|
||||
let errorMessage = ""
|
||||
try {
|
||||
await teamCreateTool.execute({ inline_spec: createSpec(), leadSessionId: "" }, createToolContext("lead-session"))
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
// then
|
||||
await expect(result).rejects.toThrow("leadSessionId")
|
||||
expect(errorMessage).toContain("leadSessionId")
|
||||
})
|
||||
|
||||
test("team_delete propagates active-member errors", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
|
||||
// when
|
||||
@@ -157,8 +171,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true succeeds even with active members", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
|
||||
// when
|
||||
@@ -171,8 +185,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true allows non-lead caller on orphaned team", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
const runtimeState = requireRuntime(created.teamRunId)
|
||||
runtimeState.status = "orphaned"
|
||||
@@ -191,8 +205,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete still rejects non-participants even with force=true", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
requireRuntime(created.teamRunId).status = "orphaned"
|
||||
|
||||
@@ -205,8 +219,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=true allows member participant to recover a stuck deleting team", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
const runtimeState = requireRuntime(created.teamRunId)
|
||||
runtimeState.status = "deleting"
|
||||
@@ -222,8 +236,8 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_delete force=false on orphaned team still requires lead", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
const runtimeState = requireRuntime(created.teamRunId)
|
||||
runtimeState.status = "orphaned"
|
||||
@@ -238,7 +252,7 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_create is idempotent for the same spec and lead session", async () => {
|
||||
// given
|
||||
const teamCreateTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const teamCreateTool = createTeamCreateToolForTest()
|
||||
|
||||
// when
|
||||
const firstResult = parseToolResult<{ teamRunId: string }>(await teamCreateTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
@@ -251,10 +265,10 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("runs full lifecycle through create, request, approve, and delete", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient)
|
||||
const approveTool = createTeamApproveShutdownTool(config, mockClient)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient, lifecycleDeps)
|
||||
const approveTool = createTeamApproveShutdownTool(config, mockClient, lifecycleDeps)
|
||||
const deleteTool = createTeamDeleteTool(config, mockClient, backgroundManager, undefined, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ inline_spec: createSpec() }, createToolContext("lead-session")))
|
||||
const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
||||
|
||||
@@ -272,9 +286,9 @@ describe("team lifecycle tools", () => {
|
||||
|
||||
test("team_reject_shutdown records the rejection reason", async () => {
|
||||
// given
|
||||
const createTool = createTeamCreateTool(config, mockClient, backgroundManager)
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient)
|
||||
const rejectTool = createTeamRejectShutdownTool(config, mockClient)
|
||||
const createTool = createTeamCreateToolForTest()
|
||||
const requestTool = createTeamShutdownRequestTool(config, mockClient, lifecycleDeps)
|
||||
const rejectTool = createTeamRejectShutdownTool(config, mockClient, lifecycleDeps)
|
||||
const created = parseToolResult<{ teamRunId: string; runtimeState: RuntimeState }>(await createTool.execute({ teamName: "alpha-team" }, createToolContext("lead-session")))
|
||||
const memberSessionId = created.runtimeState.members.find((member) => member.name === "member-a")?.sessionId
|
||||
await requestTool.execute({ teamRunId: created.teamRunId, targetMemberName: "member-a" }, createToolContext("lead-session"))
|
||||
|
||||
@@ -116,17 +116,38 @@ function parseInlineTeamSpec(
|
||||
return parsedSpec
|
||||
}
|
||||
|
||||
async function findParticipantRuntime(sessionID: string, config: TeamModeConfig): Promise<RuntimeState | undefined> {
|
||||
for (const activeTeam of await listActiveTeams(config)) {
|
||||
const runtimeState = await loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined)
|
||||
type TeamRuntimeStoreDeps = {
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
async function findParticipantRuntime(sessionID: string, config: TeamModeConfig, deps: TeamRuntimeStoreDeps): Promise<RuntimeState | undefined> {
|
||||
for (const activeTeam of await deps.listActiveTeams(config)) {
|
||||
const runtimeState = await deps.loadRuntimeState(activeTeam.teamRunId, config).catch(() => undefined)
|
||||
if (!runtimeState || !ACTIVE_RUNTIME_STATUSES.has(runtimeState.status)) continue
|
||||
if (runtimeState.leadSessionId === sessionID) return runtimeState
|
||||
if (runtimeState.members.some((member) => member.sessionId === sessionID)) return runtimeState
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
type TeamShutdownToolDeps = TeamRuntimeStoreDeps & {
|
||||
deleteTeam: typeof deleteTeam
|
||||
requestShutdownOfMember: typeof requestShutdownOfMember
|
||||
approveShutdown: typeof approveShutdown
|
||||
rejectShutdown: typeof rejectShutdown
|
||||
}
|
||||
|
||||
const defaultTeamShutdownToolDeps: TeamShutdownToolDeps = {
|
||||
listActiveTeams,
|
||||
loadRuntimeState,
|
||||
deleteTeam,
|
||||
requestShutdownOfMember,
|
||||
approveShutdown,
|
||||
rejectShutdown,
|
||||
}
|
||||
|
||||
async function resolveParticipant(teamRunId: string, sessionID: string, config: TeamModeConfig, deps: TeamRuntimeStoreDeps): Promise<{ runtimeState: RuntimeState; participant?: TeamParticipant }> {
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
if (runtimeState.leadSessionId === sessionID) {
|
||||
return { runtimeState, participant: { role: "lead", memberName: getLeadMemberName(runtimeState) } }
|
||||
}
|
||||
@@ -140,12 +161,27 @@ export type TeamCreateExecutorConfig = {
|
||||
agentOverrides?: AgentOverrides
|
||||
}
|
||||
|
||||
type TeamCreateToolDeps = {
|
||||
createTeamRun: typeof createTeamRun
|
||||
loadTeamSpec: typeof loadTeamSpec
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
const defaultTeamCreateToolDeps: TeamCreateToolDeps = {
|
||||
createTeamRun,
|
||||
loadTeamSpec,
|
||||
listActiveTeams,
|
||||
loadRuntimeState,
|
||||
}
|
||||
|
||||
export function createTeamCreateTool(
|
||||
config: TeamModeConfig,
|
||||
client: OpencodeClient,
|
||||
bgMgr: BackgroundManager,
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
executorConfig?: TeamCreateExecutorConfig,
|
||||
deps: TeamCreateToolDeps = defaultTeamCreateToolDeps,
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: "Create a team run from a named or inline team spec.",
|
||||
@@ -163,13 +199,13 @@ export function createTeamCreateTool(
|
||||
const callerTeamLead = resolveCallerTeamLead(runtimeContext.agent)
|
||||
const defaultCategoryName = resolveDefaultInlineCategory(executorConfig?.userCategories)
|
||||
const spec = args.teamName
|
||||
? await loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead })
|
||||
? await deps.loadTeamSpec(args.teamName, config, projectRoot, { callerTeamLead })
|
||||
: parseInlineTeamSpec(args.inline_spec, { callerTeamLead, defaultCategoryName })
|
||||
const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config)
|
||||
const participantRuntime = await findParticipantRuntime(runtimeContext.sessionID, config, deps)
|
||||
if (participantRuntime && (participantRuntime.teamName !== spec.name || participantRuntime.leadSessionId !== leadSessionId)) {
|
||||
throw new Error(`team_create denied: session is already a participant of team ${participantRuntime.teamRunId}`)
|
||||
}
|
||||
const runtimeState = await createTeamRun(
|
||||
const runtimeState = await deps.createTeamRun(
|
||||
spec,
|
||||
leadSessionId,
|
||||
{
|
||||
@@ -198,6 +234,7 @@ export function createTeamDeleteTool(
|
||||
client: OpencodeClient,
|
||||
backgroundManager: BackgroundManager,
|
||||
tmuxMgr?: TmuxSessionManager,
|
||||
deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps,
|
||||
): ToolDefinition {
|
||||
void client
|
||||
|
||||
@@ -207,19 +244,19 @@ export function createTeamDeleteTool(
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamDeleteArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { runtimeState, participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
const isOrphanedForceDelete = args.force === true && runtimeState.status === "orphaned"
|
||||
const isStuckDeletingForceDelete = args.force === true && runtimeState.status === "deleting"
|
||||
const isForceBypass = (isStuckDeletingForceDelete || isOrphanedForceDelete) && participant !== undefined
|
||||
if (!isForceBypass && participant?.role !== "lead") {
|
||||
throw new Error("team_delete is lead-only")
|
||||
}
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) })
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, teamName: runtimeState.teamName, deleted: true, ...(await deps.deleteTeam(args.teamRunId, config, tmuxMgr, backgroundManager, { force: args.force })) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamShutdownRequestTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -228,15 +265,15 @@ export function createTeamShutdownRequestTool(config: TeamModeConfig, client: Op
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamShutdownRequestArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (participant?.role !== "lead") throw new Error("team_shutdown_request is lead-only")
|
||||
await requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config)
|
||||
await deps.requestShutdownOfMember(args.teamRunId, args.targetMemberName, participant.memberName, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, targetMemberName: args.targetMemberName, requesterName: participant.memberName, status: "shutdown_requested" })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamApproveShutdownTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -245,15 +282,15 @@ export function createTeamApproveShutdownTool(config: TeamModeConfig, client: Op
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamApproveShutdownArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_approve_shutdown: caller must be target member or team lead")
|
||||
await approveShutdown(args.teamRunId, args.memberName, participant.memberName, config)
|
||||
await deps.approveShutdown(args.teamRunId, args.memberName, participant.memberName, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, approverName: participant.memberName, status: "shutdown_approved" })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamRejectShutdownTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamShutdownToolDeps = defaultTeamShutdownToolDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -262,9 +299,9 @@ export function createTeamRejectShutdownTool(config: TeamModeConfig, client: Ope
|
||||
async execute(rawArgs, toolContext) {
|
||||
const args = TeamRejectShutdownArgsSchema.parse(rawArgs)
|
||||
const runtimeContext = toolContext as TeamLifecycleToolContext
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config)
|
||||
const { participant } = await resolveParticipant(args.teamRunId, runtimeContext.sessionID, config, deps)
|
||||
if (!participant || (participant.role !== "lead" && participant.memberName !== args.memberName)) throw new Error("team_reject_shutdown: caller must be target member or team lead")
|
||||
await rejectShutdown(args.teamRunId, args.memberName, args.reason, config)
|
||||
await deps.rejectShutdown(args.teamRunId, args.memberName, args.reason, config)
|
||||
return JSON.stringify({ teamRunId: args.teamRunId, memberName: args.memberName, rejectedBy: participant.memberName, reason: args.reason, status: "shutdown_rejected" })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, readdir } from "node:fs/promises"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { tmpdir } from "node:os"
|
||||
@@ -10,6 +10,8 @@ import type { ToolContext } from "@opencode-ai/plugin/tool"
|
||||
|
||||
import { TeamModeConfigSchema } from "../../../config/schema/team-mode"
|
||||
import { getInboxDir, resolveBaseDir } from "../team-registry/paths"
|
||||
import type { RuntimeState } from "../types"
|
||||
import { createTeamSendMessageTool, type LiveDeliveryClient } from "./messaging"
|
||||
|
||||
function createToolContext(sessionID: string, directory: string): ToolContext {
|
||||
return {
|
||||
@@ -34,17 +36,29 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
const memberOneSessionId = randomUUID()
|
||||
const memberTwoSessionId = randomUUID()
|
||||
|
||||
const runtimeStateWithRecipientSession = {
|
||||
const runtimeStateWithRecipientSession: RuntimeState = {
|
||||
version: 1,
|
||||
teamRunId,
|
||||
teamName: "team-alpha",
|
||||
specSource: "project",
|
||||
createdAt: Date.now(),
|
||||
leadSessionId,
|
||||
status: "active",
|
||||
shutdownRequests: [],
|
||||
bounds: {
|
||||
maxMembers: 8,
|
||||
maxParallelMembers: 4,
|
||||
maxMessagesPerRun: 10000,
|
||||
maxWallClockMinutes: 120,
|
||||
maxMemberTurns: 500,
|
||||
},
|
||||
members: [
|
||||
{ name: "team-lead", agentType: "leader", sessionId: leadSessionId },
|
||||
{ name: "m1", agentType: "member", sessionId: memberOneSessionId },
|
||||
{ name: "m2", agentType: "member", sessionId: memberTwoSessionId },
|
||||
{ name: "team-lead", agentType: "leader", status: "idle", sessionId: leadSessionId, pendingInjectedMessageIds: [] },
|
||||
{ name: "m1", agentType: "general-purpose", status: "idle", sessionId: memberOneSessionId, pendingInjectedMessageIds: [] },
|
||||
{ name: "m2", agentType: "general-purpose", status: "idle", sessionId: memberTwoSessionId, pendingInjectedMessageIds: [] },
|
||||
],
|
||||
}
|
||||
const runtimeStateWithoutRecipientSession = {
|
||||
const runtimeStateWithoutRecipientSession: RuntimeState = {
|
||||
...runtimeStateWithRecipientSession,
|
||||
members: runtimeStateWithRecipientSession.members.map((member) => (
|
||||
member.name === "m2"
|
||||
@@ -54,18 +68,15 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
}
|
||||
|
||||
let loadRuntimeStateCalls = 0
|
||||
mock.module("../team-state-store/store", () => ({
|
||||
listActiveTeams: async () => [{ teamRunId }],
|
||||
const deps = {
|
||||
loadRuntimeState: async () => {
|
||||
loadRuntimeStateCalls += 1
|
||||
return loadRuntimeStateCalls >= 3
|
||||
? runtimeStateWithoutRecipientSession
|
||||
: runtimeStateWithRecipientSession
|
||||
},
|
||||
}))
|
||||
} satisfies NonNullable<Parameters<typeof createTeamSendMessageTool>[2]>
|
||||
|
||||
const { createTeamSendMessageTool } = await import("./messaging")
|
||||
type LiveDeliveryClient = Parameters<typeof createTeamSendMessageTool>[1]
|
||||
const client = {
|
||||
session: {
|
||||
promptAsync: async () => {
|
||||
@@ -73,7 +84,7 @@ describe("createTeamSendMessageTool missing recipient session fallback", () => {
|
||||
},
|
||||
},
|
||||
} satisfies LiveDeliveryClient
|
||||
const tool = createTeamSendMessageTool(config, client)
|
||||
const tool = createTeamSendMessageTool(config, client, deps)
|
||||
|
||||
// when
|
||||
const result = await tool.execute({
|
||||
|
||||
@@ -43,6 +43,14 @@ type TeamRuntimeDetails = {
|
||||
activeMembers: string[]
|
||||
}
|
||||
|
||||
export type TeamSendMessageToolDeps = {
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
}
|
||||
|
||||
const defaultTeamSendMessageToolDeps: TeamSendMessageToolDeps = {
|
||||
loadRuntimeState,
|
||||
}
|
||||
|
||||
const TeamReferenceArgsSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
@@ -53,17 +61,22 @@ const TeamSendMessageArgsSchema = z.object({
|
||||
to: z.string().min(1),
|
||||
body: z.string(),
|
||||
kind: z.enum(MESSAGE_TOOL_KINDS).optional(),
|
||||
correlationId: z.string().uuid().optional(),
|
||||
correlationId: z.uuid().optional(),
|
||||
summary: z.string().optional(),
|
||||
references: z.array(TeamReferenceArgsSchema).optional(),
|
||||
})
|
||||
|
||||
type DeliveryReservation = Awaited<ReturnType<typeof reserveMessageForDelivery>>
|
||||
|
||||
async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, config: TeamModeConfig): Promise<TeamRuntimeDetails> {
|
||||
async function resolveTeamRuntimeDetails(
|
||||
teamRunId: string,
|
||||
sessionID: string,
|
||||
config: TeamModeConfig,
|
||||
deps: TeamSendMessageToolDeps,
|
||||
): Promise<TeamRuntimeDetails> {
|
||||
const registryEntry = lookupTeamSession(sessionID)
|
||||
if (registryEntry?.teamRunId === teamRunId) {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
|
||||
return {
|
||||
teamRunId: runtimeState.teamRunId,
|
||||
@@ -76,7 +89,7 @@ async function resolveTeamRuntimeDetails(teamRunId: string, sessionID: string, c
|
||||
}
|
||||
|
||||
try {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const isLead = runtimeState.leadSessionId === sessionID
|
||||
const leadMember = isLead
|
||||
? runtimeState.members.find((member) => member.agentType === "leader")
|
||||
@@ -127,8 +140,9 @@ async function deliverLive(
|
||||
deliveredTo: readonly string[],
|
||||
config: TeamModeConfig,
|
||||
directory: string,
|
||||
deps: TeamSendMessageToolDeps,
|
||||
): Promise<void> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const envelope = buildEnvelope(message)
|
||||
|
||||
for (const recipientName of deliveredTo) {
|
||||
@@ -194,7 +208,11 @@ async function deliverLive(
|
||||
}
|
||||
}
|
||||
|
||||
export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDeliveryClient): ToolDefinition {
|
||||
export function createTeamSendMessageTool(
|
||||
config: TeamModeConfig,
|
||||
client: LiveDeliveryClient,
|
||||
deps: TeamSendMessageToolDeps = defaultTeamSendMessageToolDeps,
|
||||
): ToolDefinition {
|
||||
return tool({
|
||||
description: "Send a message to a team member or broadcast to the team.",
|
||||
args: {
|
||||
@@ -220,7 +238,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
|
||||
const targetDirectory = typeof runtimeContext.directory === "string" ? runtimeContext.directory : process.cwd()
|
||||
|
||||
const teamRuntime = await resolveTeamRuntimeDetails(args.teamRunId, sessionID, config)
|
||||
const teamRuntime = await resolveTeamRuntimeDetails(args.teamRunId, sessionID, config, deps)
|
||||
const message = MessageSchema.parse({
|
||||
version: 1,
|
||||
messageId: randomUUID(),
|
||||
@@ -242,7 +260,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
throw new BroadcastNotPermittedError()
|
||||
}
|
||||
|
||||
const runtimeState = await loadRuntimeState(teamRuntime.teamRunId, config)
|
||||
const runtimeState = await deps.loadRuntimeState(teamRuntime.teamRunId, config)
|
||||
const reservedRecipients = new Set<string>(
|
||||
runtimeState.members
|
||||
.filter((member) => member.sessionId !== undefined && member.name !== teamRuntime.senderName)
|
||||
@@ -256,7 +274,7 @@ export function createTeamSendMessageTool(config: TeamModeConfig, client: LiveDe
|
||||
})
|
||||
|
||||
try {
|
||||
await deliverLive(client, message, teamRuntime.teamRunId, result.deliveredTo, config, targetDirectory)
|
||||
await deliverLive(client, message, teamRuntime.teamRunId, result.deliveredTo, config, targetDirectory, deps)
|
||||
} catch (liveError) {
|
||||
log("[team-mailbox] deliverLive top-level error (message already in inbox, safe to ignore)", {
|
||||
error: liveError instanceof Error ? liveError.message : String(liveError),
|
||||
|
||||
@@ -24,21 +24,12 @@ let listActiveTeamsImplementation: typeof import("../team-state-store/store").li
|
||||
throw new Error("listActiveTeamsImplementation not set")
|
||||
}
|
||||
|
||||
mock.module("../team-runtime/status", () => ({
|
||||
const deps = {
|
||||
aggregateStatus: (...args: Parameters<typeof aggregateStatusImplementation>) => aggregateStatusImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-registry/paths", () => ({
|
||||
discoverTeamSpecs: (...args: Parameters<typeof discoverTeamSpecsImplementation>) => discoverTeamSpecsImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-registry/loader", () => ({
|
||||
loadTeamSpec: (...args: Parameters<typeof loadTeamSpecImplementation>) => loadTeamSpecImplementation(...args),
|
||||
}))
|
||||
|
||||
mock.module("../team-state-store/store", () => ({
|
||||
listActiveTeams: (...args: Parameters<typeof listActiveTeamsImplementation>) => listActiveTeamsImplementation(...args),
|
||||
}))
|
||||
}
|
||||
|
||||
import { createTeamListTool, createTeamStatusTool } from "./query"
|
||||
|
||||
@@ -64,7 +55,7 @@ describe("query tools", () => {
|
||||
teamName: "team-alpha",
|
||||
status: "active",
|
||||
createdAt: 1,
|
||||
members: [{ name: "worker", unreadMessages: 0 }],
|
||||
members: [{ name: "worker", status: "running", unreadMessages: 0 }],
|
||||
tasks: { pending: 0, claimed: 0, in_progress: 0, completed: 0, deleted: 0, total: 0 },
|
||||
shutdownRequests: [],
|
||||
concurrency: { runningOnSameModel: 0, queuedOnSameModel: 0 },
|
||||
@@ -76,7 +67,7 @@ describe("query tools", () => {
|
||||
expect(passedConfig).toBe(config)
|
||||
return expectedStatus
|
||||
}
|
||||
const tool = createTeamStatusTool(config, mockClient)
|
||||
const tool = createTeamStatusTool(config, mockClient, undefined, deps)
|
||||
|
||||
// when
|
||||
const result = JSON.parse(await tool.execute({ teamRunId: "team-run-1" }, createMockContext()))
|
||||
@@ -104,7 +95,7 @@ describe("query tools", () => {
|
||||
listActiveTeamsImplementation = async () => [
|
||||
{ teamRunId: "run-1", teamName: "bar", status: "active", memberCount: 3, scope: "user" },
|
||||
]
|
||||
const tool = createTeamListTool(config, mockClient)
|
||||
const tool = createTeamListTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = JSON.parse(await tool.execute({}, createMockContext()))
|
||||
|
||||
@@ -7,6 +7,20 @@ import { aggregateStatus } from "../team-runtime/status"
|
||||
import { discoverTeamSpecs } from "../team-registry/paths"
|
||||
import { listActiveTeams } from "../team-state-store/store"
|
||||
|
||||
type QueryToolDeps = {
|
||||
aggregateStatus: typeof aggregateStatus
|
||||
discoverTeamSpecs: typeof discoverTeamSpecs
|
||||
loadTeamSpec: typeof loadTeamSpec
|
||||
listActiveTeams: typeof listActiveTeams
|
||||
}
|
||||
|
||||
const defaultDeps: QueryToolDeps = {
|
||||
aggregateStatus,
|
||||
discoverTeamSpecs,
|
||||
loadTeamSpec,
|
||||
listActiveTeams,
|
||||
}
|
||||
|
||||
type TeamListScope = "user" | "project" | "all"
|
||||
|
||||
type TeamListEntry = {
|
||||
@@ -21,6 +35,7 @@ export function createTeamStatusTool(
|
||||
config: TeamModeConfig,
|
||||
client: OpencodeClient,
|
||||
backgroundManager?: Parameters<typeof aggregateStatus>[2],
|
||||
deps: QueryToolDeps = defaultDeps,
|
||||
): ToolDefinition {
|
||||
void client
|
||||
|
||||
@@ -29,11 +44,11 @@ export function createTeamStatusTool(
|
||||
args: {
|
||||
teamRunId: tool.schema.string().describe("Team run ID"),
|
||||
},
|
||||
execute: async (args: { teamRunId: string }) => JSON.stringify(await aggregateStatus(args.teamRunId, config, backgroundManager)),
|
||||
execute: async (args: { teamRunId: string }) => JSON.stringify(await deps.aggregateStatus(args.teamRunId, config, backgroundManager)),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamListTool(config: TeamModeConfig, client: OpencodeClient, deps: QueryToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -48,8 +63,8 @@ export function createTeamListTool(config: TeamModeConfig, client: OpencodeClien
|
||||
execute: async (args: { scope?: TeamListScope }) => {
|
||||
const scope = args.scope ?? "all"
|
||||
const projectRoot = process.cwd()
|
||||
const declaredTeamSpecs = await discoverTeamSpecs(config, projectRoot)
|
||||
const activeTeams = await listActiveTeams(config)
|
||||
const declaredTeamSpecs = await deps.discoverTeamSpecs(config, projectRoot)
|
||||
const activeTeams = await deps.listActiveTeams(config)
|
||||
|
||||
const filteredDeclaredTeamSpecs = scope === "all"
|
||||
? declaredTeamSpecs
|
||||
@@ -57,7 +72,7 @@ export function createTeamListTool(config: TeamModeConfig, client: OpencodeClien
|
||||
|
||||
const declaredTeamSpecsByName = new Map(
|
||||
await Promise.all(filteredDeclaredTeamSpecs.map(async (teamSpec) => {
|
||||
const loadedTeamSpec = await loadTeamSpec(teamSpec.name, config, projectRoot)
|
||||
const loadedTeamSpec = await deps.loadTeamSpec(teamSpec.name, config, projectRoot)
|
||||
return [teamSpec.name, loadedTeamSpec.members.length] as const
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -39,14 +39,14 @@ const loadRuntimeStateMock = mock(async (): Promise<RuntimeState> => ({
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("../team-state-store", () => ({ loadRuntimeState: loadRuntimeStateMock }))
|
||||
mock.module("../team-tasklist", () => ({
|
||||
const deps = {
|
||||
loadRuntimeState: loadRuntimeStateMock,
|
||||
createTask: createTaskMock,
|
||||
listTasks: listTasksMock,
|
||||
claimTask: claimTaskMock,
|
||||
updateTaskStatus: updateTaskStatusMock,
|
||||
getTask: getTaskMock,
|
||||
}))
|
||||
}
|
||||
|
||||
const {
|
||||
createTeamTaskCreateTool,
|
||||
@@ -96,10 +96,10 @@ describe("team task tools", () => {
|
||||
test("create -> list -> claim -> complete flow", async () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
const createTool = createTeamTaskCreateTool(config, mockClient)
|
||||
const listTool = createTeamTaskListTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const getTool = createTeamTaskGetTool(config, mockClient)
|
||||
const createTool = createTeamTaskCreateTool(config, mockClient, deps)
|
||||
const listTool = createTeamTaskListTool(config, mockClient, deps)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
const getTool = createTeamTaskGetTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const created = JSON.parse(await createTool.execute({ teamRunId: "team-run-1", subject: "task one", description: "desc" }, createContext("member-session-a")))
|
||||
@@ -129,7 +129,7 @@ describe("team task tools", () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
updateTaskStatusMock.mockImplementationOnce(async () => { throw new Error("CrossOwnerUpdateError") })
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "in_progress", owner: "member-b" }, createContext("member-session-a"))
|
||||
@@ -142,7 +142,7 @@ describe("team task tools", () => {
|
||||
// given
|
||||
const config = createConfig()
|
||||
claimTaskMock.mockImplementationOnce(async () => { throw new Error("blocked by 2") })
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient)
|
||||
const updateTool = createTeamTaskUpdateTool(config, mockClient, deps)
|
||||
|
||||
// when
|
||||
const result = updateTool.execute({ teamRunId: "team-run-1", taskId: "1", status: "claimed" }, createContext("member-session-a"))
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { TeamModeConfig } from "../../../config/schema/team-mode"
|
||||
import type { OpencodeClient } from "../../../tools/delegate-task/types"
|
||||
import { loadRuntimeState } from "../team-state-store"
|
||||
import { createTask, getTask, listTasks, updateTaskStatus, claimTask } from "../team-tasklist"
|
||||
import type { RuntimeState, Task } from "../types"
|
||||
|
||||
type TeamTaskToolContext = ToolContext & {
|
||||
sessionID?: string
|
||||
@@ -39,8 +40,26 @@ type TeamTaskGetArgs = {
|
||||
taskId: string
|
||||
}
|
||||
|
||||
async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sessionID: string | undefined): Promise<string> {
|
||||
const runtimeState = await loadRuntimeState(teamRunId, config)
|
||||
type TeamTaskToolDeps = {
|
||||
loadRuntimeState: typeof loadRuntimeState
|
||||
createTask: typeof createTask
|
||||
listTasks: typeof listTasks
|
||||
claimTask: typeof claimTask
|
||||
updateTaskStatus: typeof updateTaskStatus
|
||||
getTask: typeof getTask
|
||||
}
|
||||
|
||||
const defaultDeps: TeamTaskToolDeps = {
|
||||
loadRuntimeState,
|
||||
createTask,
|
||||
listTasks,
|
||||
claimTask,
|
||||
updateTaskStatus,
|
||||
getTask,
|
||||
}
|
||||
|
||||
async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sessionID: string | undefined, deps: TeamTaskToolDeps): Promise<string> {
|
||||
const runtimeState: RuntimeState = await deps.loadRuntimeState(teamRunId, config)
|
||||
const matchedMember = runtimeState.members.find((member) => member.sessionId === sessionID)
|
||||
if (matchedMember) return matchedMember.name
|
||||
|
||||
@@ -50,7 +69,7 @@ async function resolveSenderName(teamRunId: string, config: TeamModeConfig, sess
|
||||
throw new Error(`team member not found for session ${sessionID ?? "unknown"}`)
|
||||
}
|
||||
|
||||
export function createTeamTaskCreateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskCreateTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -62,7 +81,7 @@ export function createTeamTaskCreateTool(config: TeamModeConfig, client: Opencod
|
||||
blockedBy: tool.schema.array(tool.schema.string()).optional().describe("Blocking task IDs"),
|
||||
},
|
||||
execute: async (args: TeamTaskCreateArgs): Promise<string> => {
|
||||
const createdTask = await createTask(args.teamRunId, {
|
||||
const createdTask: Task = await deps.createTask(args.teamRunId, {
|
||||
subject: args.subject,
|
||||
description: args.description,
|
||||
blocks: [],
|
||||
@@ -75,7 +94,7 @@ export function createTeamTaskCreateTool(config: TeamModeConfig, client: Opencod
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -86,13 +105,13 @@ export function createTeamTaskListTool(config: TeamModeConfig, client: OpencodeC
|
||||
owner: tool.schema.string().optional(),
|
||||
},
|
||||
execute: async (args: TeamTaskListArgs): Promise<string> => {
|
||||
const tasks = await listTasks(args.teamRunId, config, { status: args.status, owner: args.owner })
|
||||
const tasks = await deps.listTasks(args.teamRunId, config, { status: args.status, owner: args.owner })
|
||||
return JSON.stringify({ tasks })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskUpdateTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskUpdateTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -104,18 +123,18 @@ export function createTeamTaskUpdateTool(config: TeamModeConfig, client: Opencod
|
||||
owner: tool.schema.string().optional().describe("Task owner"),
|
||||
},
|
||||
execute: async (args: TeamTaskUpdateArgs, ctx?: TeamTaskToolContext): Promise<string> => {
|
||||
const senderName = await resolveSenderName(args.teamRunId, config, ctx?.sessionID)
|
||||
const senderName = await resolveSenderName(args.teamRunId, config, ctx?.sessionID, deps)
|
||||
|
||||
const updatedTask = args.status === "claimed"
|
||||
? await claimTask(args.teamRunId, args.taskId, senderName, config)
|
||||
: await updateTaskStatus(args.teamRunId, args.taskId, args.status, args.owner ?? senderName, config)
|
||||
? await deps.claimTask(args.teamRunId, args.taskId, senderName, config)
|
||||
: await deps.updateTaskStatus(args.teamRunId, args.taskId, args.status, args.owner ?? senderName, config)
|
||||
|
||||
return JSON.stringify({ task: updatedTask })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeClient): ToolDefinition {
|
||||
export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeClient, deps: TeamTaskToolDeps = defaultDeps): ToolDefinition {
|
||||
void client
|
||||
|
||||
return tool({
|
||||
@@ -125,7 +144,7 @@ export function createTeamTaskGetTool(config: TeamModeConfig, client: OpencodeCl
|
||||
taskId: tool.schema.string().describe("Task ID"),
|
||||
},
|
||||
execute: async (args: TeamTaskGetArgs): Promise<string> => {
|
||||
const task = await getTask(args.teamRunId, args.taskId, config)
|
||||
const task = await deps.getTask(args.teamRunId, args.taskId, config)
|
||||
return JSON.stringify({ task })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -22,6 +22,22 @@ type SessionReadyWaitParams = {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
type TmuxSessionManagerContext = ConstructorParameters<typeof import('./manager').TmuxSessionManager>[0]
|
||||
|
||||
type TmuxSessionManagerInternals = {
|
||||
serverUrl: string
|
||||
deferredQueue: string[]
|
||||
tryAttachDeferredSession: () => Promise<void>
|
||||
}
|
||||
|
||||
function cast<TValue>(value: unknown): TValue {
|
||||
return value as TValue
|
||||
}
|
||||
|
||||
function getManagerInternals(manager: TmuxSessionManagerType): TmuxSessionManagerInternals {
|
||||
return cast<TmuxSessionManagerInternals>(manager)
|
||||
}
|
||||
|
||||
const mockQueryWindowState = mock<(paneId: string) => Promise<WindowState | null>>(
|
||||
async () => ({
|
||||
windowWidth: 212,
|
||||
@@ -78,6 +94,8 @@ const mockTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: mockIsInsideTmux,
|
||||
getCurrentPaneId: mockGetCurrentPaneId,
|
||||
queryWindowState: mockQueryWindowState,
|
||||
waitForSessionReady: mockWaitForSessionReady,
|
||||
log: (...args) => sharedModule.log(...args),
|
||||
}
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
@@ -119,8 +137,8 @@ const readySessions = new Set<string>()
|
||||
function createMockContext(overrides?: {
|
||||
sessionStatusResult?: { data?: Record<string, { type: string }> }
|
||||
sessionMessagesResult?: { data?: unknown[] }
|
||||
}) {
|
||||
return {
|
||||
}): TmuxSessionManagerContext {
|
||||
return cast<TmuxSessionManagerContext>({
|
||||
serverUrl: new URL('http://localhost:4096'),
|
||||
client: {
|
||||
session: {
|
||||
@@ -145,7 +163,7 @@ function createMockContext(overrides?: {
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any
|
||||
})
|
||||
}
|
||||
|
||||
function createSessionCreatedEvent(
|
||||
@@ -374,7 +392,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
// then
|
||||
expect((manager as any).serverUrl).toBe('http://localhost:4096')
|
||||
expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:4096')
|
||||
})
|
||||
|
||||
test('falls back to configured OPENCODE_PORT when serverUrl has port 0', async () => {
|
||||
@@ -406,7 +424,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
// then
|
||||
expect((manager as any).serverUrl).toBe('http://localhost:5678')
|
||||
expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:5678')
|
||||
})
|
||||
|
||||
test('ignores invalid OPENCODE_PORT when serverUrl has port 0', async () => {
|
||||
@@ -438,7 +456,7 @@ describe('TmuxSessionManager', () => {
|
||||
}
|
||||
|
||||
// then
|
||||
expect((manager as any).serverUrl).toBe('http://localhost:4096')
|
||||
expect(getManagerInternals(manager).serverUrl).toBe('http://localhost:4096')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -808,7 +826,7 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
// then - with small window, manager defers instead of replacing
|
||||
expect(mockExecuteActions).toHaveBeenCalledTimes(0)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_new'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_new'])
|
||||
})
|
||||
|
||||
test('keeps deferred queue idempotent for duplicate session.created events', async () => {
|
||||
@@ -850,7 +868,7 @@ describe('TmuxSessionManager', () => {
|
||||
)
|
||||
|
||||
// then
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_dup'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_dup'])
|
||||
})
|
||||
|
||||
test('auto-attaches deferred sessions in FIFO order', async () => {
|
||||
@@ -900,17 +918,17 @@ describe('TmuxSessionManager', () => {
|
||||
await manager.onSessionCreated(createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1'))
|
||||
await manager.onSessionCreated(createSessionCreatedEvent('ses_2', 'ses_parent', 'Task 2'))
|
||||
await manager.onSessionCreated(createSessionCreatedEvent('ses_3', 'ses_parent', 'Task 3'))
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_1', 'ses_2', 'ses_3'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_1', 'ses_2', 'ses_3'])
|
||||
|
||||
// when
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
await (manager as any).tryAttachDeferredSession()
|
||||
await (manager as any).tryAttachDeferredSession()
|
||||
await (manager as any).tryAttachDeferredSession()
|
||||
await getManagerInternals(manager).tryAttachDeferredSession()
|
||||
await getManagerInternals(manager).tryAttachDeferredSession()
|
||||
await getManagerInternals(manager).tryAttachDeferredSession()
|
||||
|
||||
// then
|
||||
expect(attachOrder).toEqual(['ses_1', 'ses_2', 'ses_3'])
|
||||
expect((manager as any).deferredQueue).toEqual([])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual([])
|
||||
})
|
||||
|
||||
test('does not attach deferred session more than once across repeated retries', async () => {
|
||||
@@ -963,12 +981,12 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
// when
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
await (manager as any).tryAttachDeferredSession()
|
||||
await (manager as any).tryAttachDeferredSession()
|
||||
await getManagerInternals(manager).tryAttachDeferredSession()
|
||||
await getManagerInternals(manager).tryAttachDeferredSession()
|
||||
|
||||
// then
|
||||
expect(attachCount).toBe(1)
|
||||
expect((manager as any).deferredQueue).toEqual([])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual([])
|
||||
})
|
||||
|
||||
test('skips deferred attach when the session is already pending through another spawn path', async () => {
|
||||
@@ -998,7 +1016,7 @@ describe('TmuxSessionManager', () => {
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_pending_race', 'ses_parent', 'Pending Race Task')
|
||||
)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_pending_race'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_pending_race'])
|
||||
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
Reflect.get(manager, 'pendingSessions').add('ses_pending_race')
|
||||
@@ -1008,7 +1026,7 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
// then
|
||||
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_pending_race'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_pending_race'])
|
||||
})
|
||||
|
||||
test('drops deferred sessions that were already closed by polling', async () => {
|
||||
@@ -1038,7 +1056,7 @@ describe('TmuxSessionManager', () => {
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_bounce', 'ses_parent', 'Bounce Task')
|
||||
)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_bounce'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_bounce'])
|
||||
|
||||
mockQueryWindowState.mockImplementation(async () => createWindowState())
|
||||
Reflect.set(manager, 'closedByPolling', new Set(['ses_bounce']))
|
||||
@@ -1048,7 +1066,7 @@ describe('TmuxSessionManager', () => {
|
||||
|
||||
// then
|
||||
expect(mockSpawnTmuxPane).toHaveBeenCalledTimes(0)
|
||||
expect((manager as any).deferredQueue).toEqual([])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual([])
|
||||
})
|
||||
|
||||
test('removes deferred session when session is deleted before attach', async () => {
|
||||
@@ -1084,13 +1102,13 @@ describe('TmuxSessionManager', () => {
|
||||
await manager.onSessionCreated(
|
||||
createSessionCreatedEvent('ses_pending', 'ses_parent', 'Pending Task')
|
||||
)
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_pending'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_pending'])
|
||||
|
||||
// when
|
||||
await manager.onSessionDeleted({ sessionID: 'ses_pending' })
|
||||
|
||||
// then
|
||||
expect((manager as any).deferredQueue).toEqual([])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual([])
|
||||
expect(mockExecuteAction).toHaveBeenCalledTimes(0)
|
||||
})
|
||||
|
||||
@@ -1179,7 +1197,7 @@ describe('TmuxSessionManager', () => {
|
||||
)
|
||||
|
||||
// then
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_null_state'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_null_state'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
@@ -1269,7 +1287,7 @@ describe('TmuxSessionManager', () => {
|
||||
)
|
||||
|
||||
// then
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_fail_no_close'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_fail_no_close'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
@@ -1315,7 +1333,7 @@ describe('TmuxSessionManager', () => {
|
||||
)
|
||||
|
||||
// then
|
||||
expect((manager as any).deferredQueue).toEqual(['ses_fail_with_close'])
|
||||
expect(getManagerInternals(manager).deferredQueue).toEqual(['ses_fail_with_close'])
|
||||
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TmuxConfig } from "../../config/schema"
|
||||
import type { TrackedSession, CapacityConfig, WindowState } from "./types"
|
||||
import { log } from "../../shared"
|
||||
import * as sharedModule from "../../shared"
|
||||
import {
|
||||
isInsideTmux as defaultIsInsideTmux,
|
||||
getCurrentPaneId as defaultGetCurrentPaneId,
|
||||
@@ -53,12 +53,16 @@ export interface TmuxUtilDeps {
|
||||
isInsideTmux: () => boolean
|
||||
getCurrentPaneId: () => string | undefined
|
||||
queryWindowState: (paneId: string) => Promise<WindowState | null>
|
||||
waitForSessionReady: (params: { client: OpencodeClient; sessionId: string }) => Promise<boolean>
|
||||
log: typeof sharedModule.log
|
||||
}
|
||||
|
||||
const defaultTmuxDeps: TmuxUtilDeps = {
|
||||
isInsideTmux: defaultIsInsideTmux,
|
||||
getCurrentPaneId: defaultGetCurrentPaneId,
|
||||
queryWindowState: defaultQueryWindowState,
|
||||
waitForSessionReady,
|
||||
log: sharedModule.log,
|
||||
}
|
||||
|
||||
const DEFERRED_SESSION_TTL_MS = 5 * 60 * 1000
|
||||
@@ -92,11 +96,11 @@ export class TmuxSessionManager {
|
||||
private isolatedContainerNullStateCount = 0
|
||||
private staleSweepCompleted = false
|
||||
private staleSweepInProgress = false
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
|
||||
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: Partial<TmuxUtilDeps> = {}) {
|
||||
this.client = ctx.client
|
||||
this.tmuxConfig = tmuxConfig
|
||||
this.projectDirectory = ctx.directory || process.cwd()
|
||||
this.deps = deps
|
||||
this.deps = { ...defaultTmuxDeps, ...deps }
|
||||
const configuredPort = process.env.OPENCODE_PORT
|
||||
const parsedPort = configuredPort ? Number(configuredPort) : 4096
|
||||
const defaultPort = Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535
|
||||
@@ -113,20 +117,20 @@ export class TmuxSessionManager {
|
||||
this.serverUrl = fallbackUrl
|
||||
}
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] failed to parse server URL, using fallback", {
|
||||
this.deps.log("[tmux-session-manager] failed to parse server URL, using fallback", {
|
||||
serverUrl: rawServerUrl,
|
||||
error: String(error),
|
||||
})
|
||||
this.serverUrl = fallbackUrl
|
||||
}
|
||||
this.sourcePaneId = deps.getCurrentPaneId()
|
||||
this.sourcePaneId = this.deps.getCurrentPaneId()
|
||||
this.pollingManager = new TmuxPollingManager(
|
||||
this.client,
|
||||
this.sessions,
|
||||
this.closeSessionFromPolling.bind(this),
|
||||
this.retryPendingCloses.bind(this)
|
||||
)
|
||||
log("[tmux-session-manager] initialized", {
|
||||
this.deps.log("[tmux-session-manager] initialized", {
|
||||
configEnabled: this.tmuxConfig.enabled,
|
||||
tmuxConfig: this.tmuxConfig,
|
||||
projectDirectory: this.projectDirectory,
|
||||
@@ -156,7 +160,7 @@ export class TmuxSessionManager {
|
||||
if (!this.isIsolated()) return null
|
||||
if (this.isolatedWindowPaneId) {
|
||||
const state = await this.deps.queryWindowState(this.isolatedWindowPaneId).catch((error) => {
|
||||
log("[tmux-session-manager] failed to query isolated window state", {
|
||||
this.deps.log("[tmux-session-manager] failed to query isolated window state", {
|
||||
paneId: this.isolatedWindowPaneId,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -167,7 +171,7 @@ export class TmuxSessionManager {
|
||||
return null
|
||||
}
|
||||
this.isolatedContainerNullStateCount += 1
|
||||
log("[tmux-session-manager] isolated container state query returned null", {
|
||||
this.deps.log("[tmux-session-manager] isolated container state query returned null", {
|
||||
paneId: this.isolatedWindowPaneId,
|
||||
nullStateCount: this.isolatedContainerNullStateCount,
|
||||
maxNullStateCount: MAX_ISOLATED_CONTAINER_NULL_STATE_COUNT,
|
||||
@@ -181,7 +185,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
const isolation = this.tmuxConfig.isolation
|
||||
log("[tmux-session-manager] creating isolated tmux container", { isolation, sessionId, title })
|
||||
this.deps.log("[tmux-session-manager] creating isolated tmux container", { isolation, sessionId, title })
|
||||
|
||||
const result = isolation === "session"
|
||||
? await spawnTmuxSession(sessionId, title, this.tmuxConfig, this.serverUrl, this.projectDirectory, this.sourcePaneId)
|
||||
@@ -191,13 +195,13 @@ export class TmuxSessionManager {
|
||||
this.isolatedContainerPaneId = result.paneId
|
||||
this.isolatedWindowPaneId = result.paneId
|
||||
this.isolatedContainerNullStateCount = 0
|
||||
log("[tmux-session-manager] isolated container created", {
|
||||
this.deps.log("[tmux-session-manager] isolated container created", {
|
||||
isolation,
|
||||
paneId: result.paneId,
|
||||
})
|
||||
return result.paneId
|
||||
}
|
||||
log("[tmux-session-manager] failed to create isolated container", { isolation, sessionId })
|
||||
this.deps.log("[tmux-session-manager] failed to create isolated container", { isolation, sessionId })
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -242,7 +246,7 @@ export class TmuxSessionManager {
|
||||
|
||||
this.isolatedContainerNullStateCount = 0
|
||||
this.isolatedWindowPaneId = nextAnchor.paneId
|
||||
log("[tmux-session-manager] reassigned isolated container anchor pane", {
|
||||
this.deps.log("[tmux-session-manager] reassigned isolated container anchor pane", {
|
||||
sessionId: nextAnchor.sessionId,
|
||||
paneId: nextAnchor.paneId,
|
||||
})
|
||||
@@ -288,13 +292,13 @@ export class TmuxSessionManager {
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
log("[tmux-session-manager] failed to close isolated container pane after anchor session deletion", {
|
||||
this.deps.log("[tmux-session-manager] failed to close isolated container pane after anchor session deletion", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: isolatedContainerPaneId,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] failed to cleanup isolated container pane after anchor session deletion", {
|
||||
this.deps.log("[tmux-session-manager] failed to cleanup isolated container pane after anchor session deletion", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: isolatedContainerPaneId,
|
||||
error: String(error),
|
||||
@@ -307,7 +311,7 @@ export class TmuxSessionManager {
|
||||
if (!tracked) return
|
||||
|
||||
this.sessions.set(sessionId, markTrackedSessionClosePending(tracked))
|
||||
log("[tmux-session-manager] marked session close pending", {
|
||||
this.deps.log("[tmux-session-manager] marked session close pending", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
closeRetryCount: tracked.closeRetryCount,
|
||||
@@ -321,7 +325,7 @@ export class TmuxSessionManager {
|
||||
try {
|
||||
return await this.deps.queryWindowState(paneId)
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] failed to query window state for close", {
|
||||
this.deps.log("[tmux-session-manager] failed to query window state for close", {
|
||||
error: String(error),
|
||||
})
|
||||
return null
|
||||
@@ -339,7 +343,7 @@ export class TmuxSessionManager {
|
||||
): Promise<boolean> {
|
||||
const state = await this.queryWindowStateSafely()
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] unable to verify pane after max close retries; keeping session tracked", {
|
||||
this.deps.log("[tmux-session-manager] unable to verify pane after max close retries; keeping session tracked", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
source,
|
||||
@@ -348,7 +352,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
if (this.windowStateContainsPane(state, tracked.paneId)) {
|
||||
log("[tmux-session-manager] pane still exists after max close retries; manual intervention required", {
|
||||
this.deps.log("[tmux-session-manager] pane still exists after max close retries; manual intervention required", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
source,
|
||||
@@ -356,7 +360,7 @@ export class TmuxSessionManager {
|
||||
return false
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] pane already gone after max close retries; finalizing tracked close", {
|
||||
this.deps.log("[tmux-session-manager] pane already gone after max close retries; finalizing tracked close", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
source,
|
||||
@@ -389,7 +393,7 @@ export class TmuxSessionManager {
|
||||
|
||||
return result.success
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] close session pane failed", {
|
||||
this.deps.log("[tmux-session-manager] close session pane failed", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
error: String(error),
|
||||
@@ -444,7 +448,7 @@ export class TmuxSessionManager {
|
||||
|
||||
const closed = await this.closeTrackedSession(tracked)
|
||||
if (closed) {
|
||||
log("[tmux-session-manager] retried close succeeded", {
|
||||
this.deps.log("[tmux-session-manager] retried close succeeded", {
|
||||
sessionId: tracked.sessionId,
|
||||
paneId: tracked.paneId,
|
||||
closeRetryCount: tracked.closeRetryCount,
|
||||
@@ -468,7 +472,7 @@ export class TmuxSessionManager {
|
||||
closePending: true,
|
||||
closeRetryCount: nextRetryCount,
|
||||
})
|
||||
log("[tmux-session-manager] retried close failed", {
|
||||
this.deps.log("[tmux-session-manager] retried close failed", {
|
||||
sessionId: currentTracked.sessionId,
|
||||
paneId: currentTracked.paneId,
|
||||
closeRetryCount: nextRetryCount,
|
||||
@@ -497,7 +501,7 @@ export class TmuxSessionManager {
|
||||
return
|
||||
}
|
||||
if (this.deferredQueue.length >= MAX_DEFERRED_QUEUE_SIZE) {
|
||||
log("[tmux-session-manager] deferred queue full, dropping session", {
|
||||
this.deps.log("[tmux-session-manager] deferred queue full, dropping session", {
|
||||
sessionId,
|
||||
queueLength: this.deferredQueue.length,
|
||||
maxQueueSize: MAX_DEFERRED_QUEUE_SIZE,
|
||||
@@ -511,7 +515,7 @@ export class TmuxSessionManager {
|
||||
retryIsolatedContainer,
|
||||
})
|
||||
this.deferredQueue.push(sessionId)
|
||||
log("[tmux-session-manager] deferred session queued", {
|
||||
this.deps.log("[tmux-session-manager] deferred session queued", {
|
||||
sessionId,
|
||||
queueLength: this.deferredQueue.length,
|
||||
})
|
||||
@@ -521,7 +525,7 @@ export class TmuxSessionManager {
|
||||
private removeDeferredSession(sessionId: string): void {
|
||||
if (!this.deferredSessions.delete(sessionId)) return
|
||||
this.deferredQueue = this.deferredQueue.filter((id) => id !== sessionId)
|
||||
log("[tmux-session-manager] deferred session removed", {
|
||||
this.deps.log("[tmux-session-manager] deferred session removed", {
|
||||
sessionId,
|
||||
queueLength: this.deferredQueue.length,
|
||||
})
|
||||
@@ -544,7 +548,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
})
|
||||
}, POLL_INTERVAL_BACKGROUND_MS)
|
||||
log("[tmux-session-manager] deferred attach polling started", {
|
||||
this.deps.log("[tmux-session-manager] deferred attach polling started", {
|
||||
intervalMs: POLL_INTERVAL_BACKGROUND_MS,
|
||||
})
|
||||
}
|
||||
@@ -555,7 +559,7 @@ export class TmuxSessionManager {
|
||||
this.deferredAttachInterval = undefined
|
||||
this.deferredAttachTickScheduled = false
|
||||
this.nullStateCount = 0
|
||||
log("[tmux-session-manager] deferred attach polling stopped")
|
||||
this.deps.log("[tmux-session-manager] deferred attach polling stopped")
|
||||
}
|
||||
|
||||
private beginPendingSession(
|
||||
@@ -567,7 +571,7 @@ export class TmuxSessionManager {
|
||||
|| this.pendingSessions.has(sessionId)
|
||||
|| (!options?.allowDeferredSession && this.deferredSessions.has(sessionId))
|
||||
) {
|
||||
log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
this.deps.log("[tmux-session-manager] session already tracked or pending", { sessionId })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -580,7 +584,7 @@ export class TmuxSessionManager {
|
||||
stage: SpawnStage,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const ready = await waitForSessionReady({
|
||||
const ready = await this.deps.waitForSessionReady({
|
||||
client: this.client,
|
||||
sessionId,
|
||||
})
|
||||
@@ -590,14 +594,14 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
const readinessError = new Error("Session readiness timed out")
|
||||
log("[tmux-session-manager] session readiness failed before spawn", {
|
||||
this.deps.log("[tmux-session-manager] session readiness failed before spawn", {
|
||||
sessionId,
|
||||
stage,
|
||||
error: String(readinessError),
|
||||
})
|
||||
return false
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] session readiness failed before spawn", {
|
||||
this.deps.log("[tmux-session-manager] session readiness failed before spawn", {
|
||||
sessionId,
|
||||
stage,
|
||||
error: String(error),
|
||||
@@ -612,7 +616,7 @@ export class TmuxSessionManager {
|
||||
const allStatuses = parseSessionStatusMap(statusResult.data)
|
||||
return allStatuses[sessionId]?.type
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] failed to read session status before spawn", {
|
||||
this.deps.log("[tmux-session-manager] failed to read session status before spawn", {
|
||||
sessionId,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -672,7 +676,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
this.failedReadinessSessions.delete(sessionId)
|
||||
log("[tmux-session-manager] expired failed readiness session", {
|
||||
this.deps.log("[tmux-session-manager] expired failed readiness session", {
|
||||
sessionId,
|
||||
ttlMs: FAILED_READINESS_SESSION_TTL_MS,
|
||||
})
|
||||
@@ -694,7 +698,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
this.failedReadinessSessions.delete(sessionId)
|
||||
log("[tmux-session-manager] expired failed readiness session on access", {
|
||||
this.deps.log("[tmux-session-manager] expired failed readiness session on access", {
|
||||
sessionId,
|
||||
ttlMs: FAILED_READINESS_SESSION_TTL_MS,
|
||||
})
|
||||
@@ -724,7 +728,7 @@ export class TmuxSessionManager {
|
||||
|
||||
const sessionStatus = await this.getSessionStatusType(sessionId)
|
||||
if (!isAttachableSessionStatus(sessionStatus)) {
|
||||
log("[tmux-session-manager] session not attachable for pane spawn", {
|
||||
this.deps.log("[tmux-session-manager] session not attachable for pane spawn", {
|
||||
sessionId,
|
||||
stage,
|
||||
status: sessionStatus,
|
||||
@@ -744,7 +748,7 @@ export class TmuxSessionManager {
|
||||
createTrackedSession({ sessionId, paneId: isolatedPaneId, description: title }),
|
||||
)
|
||||
this.pollingManager.startPolling()
|
||||
log("[tmux-session-manager] first subagent spawned in isolated window", {
|
||||
this.deps.log("[tmux-session-manager] first subagent spawned in isolated window", {
|
||||
sessionId,
|
||||
paneId: isolatedPaneId,
|
||||
})
|
||||
@@ -752,24 +756,24 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
if (this.isIsolated() && !this.isolatedWindowPaneId) {
|
||||
log("[tmux-session-manager] isolated container failed, deferring session for retry", { sessionId })
|
||||
this.deps.log("[tmux-session-manager] isolated container failed, deferring session for retry", { sessionId })
|
||||
this.enqueueDeferredSession(sessionId, title, true)
|
||||
return
|
||||
}
|
||||
const sourcePaneId = this.getEffectiveSourcePaneId()
|
||||
if (!sourcePaneId) {
|
||||
log("[tmux-session-manager] no effective source pane id")
|
||||
this.deps.log("[tmux-session-manager] no effective source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
const state = await this.deps.queryWindowState(sourcePaneId)
|
||||
if (!state) {
|
||||
log("[tmux-session-manager] failed to query window state, deferring session")
|
||||
this.deps.log("[tmux-session-manager] failed to query window state, deferring session")
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] window state queried", {
|
||||
this.deps.log("[tmux-session-manager] window state queried", {
|
||||
windowWidth: state.windowWidth,
|
||||
mainPane: state.mainPane?.paneId,
|
||||
agentPaneCount: state.agentPanes.length,
|
||||
@@ -784,7 +788,7 @@ export class TmuxSessionManager {
|
||||
this.getSessionMappings(),
|
||||
)
|
||||
|
||||
log("[tmux-session-manager] spawn decision", {
|
||||
this.deps.log("[tmux-session-manager] spawn decision", {
|
||||
canSpawn: decision.canSpawn,
|
||||
reason: decision.reason,
|
||||
actionCount: decision.actions.length,
|
||||
@@ -802,7 +806,7 @@ export class TmuxSessionManager {
|
||||
})
|
||||
|
||||
if (!decision.canSpawn) {
|
||||
log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
this.deps.log("[tmux-session-manager] cannot spawn", { reason: decision.reason })
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
return
|
||||
}
|
||||
@@ -821,13 +825,13 @@ export class TmuxSessionManager {
|
||||
for (const { action, result: actionResult } of result.results) {
|
||||
if (action.type === "close" && actionResult.success) {
|
||||
this.sessions.delete(action.sessionId)
|
||||
log("[tmux-session-manager] removed closed session from cache", {
|
||||
this.deps.log("[tmux-session-manager] removed closed session from cache", {
|
||||
sessionId: action.sessionId,
|
||||
})
|
||||
}
|
||||
if (action.type === "replace" && actionResult.success) {
|
||||
this.sessions.delete(action.oldSessionId)
|
||||
log("[tmux-session-manager] removed replaced session from cache", {
|
||||
this.deps.log("[tmux-session-manager] removed replaced session from cache", {
|
||||
oldSessionId: action.oldSessionId,
|
||||
newSessionId: action.newSessionId,
|
||||
})
|
||||
@@ -844,7 +848,7 @@ export class TmuxSessionManager {
|
||||
}),
|
||||
)
|
||||
this.clearFailedReadinessSession(sessionId)
|
||||
log("[tmux-session-manager] pane spawned and tracked", {
|
||||
this.deps.log("[tmux-session-manager] pane spawned and tracked", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
@@ -852,7 +856,7 @@ export class TmuxSessionManager {
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] spawn failed", {
|
||||
this.deps.log("[tmux-session-manager] spawn failed", {
|
||||
success: result.success,
|
||||
results: result.results.map((resultEntry) => ({
|
||||
type: resultEntry.action.type,
|
||||
@@ -861,7 +865,7 @@ export class TmuxSessionManager {
|
||||
})),
|
||||
})
|
||||
|
||||
log("[tmux-session-manager] re-queueing deferred session after spawn failure", {
|
||||
this.deps.log("[tmux-session-manager] re-queueing deferred session after spawn failure", {
|
||||
sessionId,
|
||||
})
|
||||
this.enqueueDeferredSession(sessionId, title)
|
||||
@@ -906,7 +910,7 @@ export class TmuxSessionManager {
|
||||
try {
|
||||
const sessionStatus = await this.getSessionStatusType(sessionId)
|
||||
if (!isAttachableSessionStatus(sessionStatus)) {
|
||||
log("[tmux-session-manager] session.idle retry skipped because session is not attachable", {
|
||||
this.deps.log("[tmux-session-manager] session.idle retry skipped because session is not attachable", {
|
||||
sessionId,
|
||||
status: sessionStatus,
|
||||
})
|
||||
@@ -954,7 +958,7 @@ export class TmuxSessionManager {
|
||||
if (Date.now() - deferred.queuedAt.getTime() > DEFERRED_SESSION_TTL_MS) {
|
||||
this.deferredQueue.shift()
|
||||
this.deferredSessions.delete(sessionId)
|
||||
log("[tmux-session-manager] deferred session expired", {
|
||||
this.deps.log("[tmux-session-manager] deferred session expired", {
|
||||
sessionId,
|
||||
queuedAt: deferred.queuedAt.toISOString(),
|
||||
ttlMs: DEFERRED_SESSION_TTL_MS,
|
||||
@@ -988,7 +992,7 @@ export class TmuxSessionManager {
|
||||
)
|
||||
this.removeDeferredSession(sessionId)
|
||||
this.pollingManager.startPolling()
|
||||
log("[tmux-session-manager] deferred session attached in isolated window", {
|
||||
this.deps.log("[tmux-session-manager] deferred session attached in isolated window", {
|
||||
sessionId,
|
||||
paneId: isolatedPaneId,
|
||||
})
|
||||
@@ -1002,11 +1006,11 @@ export class TmuxSessionManager {
|
||||
const state = await this.deps.queryWindowState(effectiveSourcePaneId)
|
||||
if (!state) {
|
||||
this.nullStateCount += 1
|
||||
log("[tmux-session-manager] deferred attach window state is null", {
|
||||
this.deps.log("[tmux-session-manager] deferred attach window state is null", {
|
||||
nullStateCount: this.nullStateCount,
|
||||
})
|
||||
if (this.nullStateCount >= 3) {
|
||||
log("[tmux-session-manager] stopping deferred attach loop after consecutive null states", {
|
||||
this.deps.log("[tmux-session-manager] stopping deferred attach loop after consecutive null states", {
|
||||
nullStateCount: this.nullStateCount,
|
||||
})
|
||||
this.stopDeferredAttachLoop()
|
||||
@@ -1024,7 +1028,7 @@ export class TmuxSessionManager {
|
||||
)
|
||||
|
||||
if (!decision.canSpawn || decision.actions.length === 0) {
|
||||
log("[tmux-session-manager] deferred session still waiting for capacity", {
|
||||
this.deps.log("[tmux-session-manager] deferred session still waiting for capacity", {
|
||||
sessionId,
|
||||
reason: decision.reason,
|
||||
})
|
||||
@@ -1049,7 +1053,7 @@ export class TmuxSessionManager {
|
||||
})
|
||||
|
||||
if (!result.success || !result.spawnedPaneId) {
|
||||
log("[tmux-session-manager] deferred session attach failed", {
|
||||
this.deps.log("[tmux-session-manager] deferred session attach failed", {
|
||||
sessionId,
|
||||
results: result.results.map((r) => ({
|
||||
type: r.action.type,
|
||||
@@ -1070,7 +1074,7 @@ export class TmuxSessionManager {
|
||||
)
|
||||
this.removeDeferredSession(sessionId)
|
||||
this.pollingManager.startPolling()
|
||||
log("[tmux-session-manager] deferred session attached", {
|
||||
this.deps.log("[tmux-session-manager] deferred session attached", {
|
||||
sessionId,
|
||||
paneId: result.spawnedPaneId,
|
||||
})
|
||||
@@ -1081,7 +1085,7 @@ export class TmuxSessionManager {
|
||||
|
||||
async onSessionCreated(event: SessionCreatedEvent): Promise<void> {
|
||||
const enabled = this.isEnabled()
|
||||
log("[tmux-session-manager] onSessionCreated called", {
|
||||
this.deps.log("[tmux-session-manager] onSessionCreated called", {
|
||||
enabled,
|
||||
tmuxConfigEnabled: this.tmuxConfig.enabled,
|
||||
isInsideTmux: this.deps.isInsideTmux(),
|
||||
@@ -1100,7 +1104,7 @@ export class TmuxSessionManager {
|
||||
const title = info.title ?? "Subagent"
|
||||
|
||||
if (!this.sourcePaneId) {
|
||||
log("[tmux-session-manager] no source pane id")
|
||||
this.deps.log("[tmux-session-manager] no source pane id")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1133,13 +1137,13 @@ export class TmuxSessionManager {
|
||||
private async enqueueSpawn(run: () => Promise<void>): Promise<void> {
|
||||
this.spawnQueue = this.spawnQueue
|
||||
.catch((error) => {
|
||||
log("[tmux-session-manager] recovering spawn queue after previous failure", {
|
||||
this.deps.log("[tmux-session-manager] recovering spawn queue after previous failure", {
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
.then(run)
|
||||
.catch((err) => {
|
||||
log("[tmux-session-manager] spawn queue task failed", {
|
||||
this.deps.log("[tmux-session-manager] spawn queue task failed", {
|
||||
error: String(err),
|
||||
})
|
||||
})
|
||||
@@ -1158,7 +1162,7 @@ export class TmuxSessionManager {
|
||||
const tracked = this.sessions.get(event.sessionID)
|
||||
if (!tracked) return
|
||||
|
||||
log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
this.deps.log("[tmux-session-manager] onSessionDeleted", { sessionId: event.sessionID })
|
||||
|
||||
const state = await this.queryWindowStateSafely()
|
||||
if (!state) {
|
||||
@@ -1193,7 +1197,7 @@ export class TmuxSessionManager {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] failed to close pane for deleted session", {
|
||||
this.deps.log("[tmux-session-manager] failed to close pane for deleted session", {
|
||||
sessionId: event.sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -1218,7 +1222,7 @@ export class TmuxSessionManager {
|
||||
return
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] closing session pane", {
|
||||
this.deps.log("[tmux-session-manager] closing session pane", {
|
||||
sessionId,
|
||||
paneId: tracked.paneId,
|
||||
})
|
||||
@@ -1240,7 +1244,7 @@ export class TmuxSessionManager {
|
||||
return false
|
||||
}
|
||||
|
||||
log("[tmux-session-manager] skipping tmux respawn because polling already closed the session", {
|
||||
this.deps.log("[tmux-session-manager] skipping tmux respawn because polling already closed the session", {
|
||||
sessionId,
|
||||
source,
|
||||
})
|
||||
@@ -1256,7 +1260,7 @@ export class TmuxSessionManager {
|
||||
}
|
||||
|
||||
void this.retryFailedReadinessSession(sessionId).catch((error) => {
|
||||
log("[tmux-session-manager] session.idle retry failed", {
|
||||
this.deps.log("[tmux-session-manager] session.idle retry failed", {
|
||||
sessionId,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -1279,14 +1283,14 @@ export class TmuxSessionManager {
|
||||
this.pollingManager.stopPolling()
|
||||
|
||||
if (this.sessions.size > 0) {
|
||||
log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||
this.deps.log("[tmux-session-manager] closing all panes", { count: this.sessions.size })
|
||||
|
||||
const sessionIds = Array.from(this.sessions.keys())
|
||||
for (const sessionId of sessionIds) {
|
||||
try {
|
||||
await this.closeSessionById(sessionId)
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] cleanup error for pane", {
|
||||
this.deps.log("[tmux-session-manager] cleanup error for pane", {
|
||||
sessionId,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -1303,12 +1307,12 @@ export class TmuxSessionManager {
|
||||
const isolatedSessionName = getIsolatedSessionName()
|
||||
try {
|
||||
const killed = await killTmuxSessionIfExists(isolatedSessionName)
|
||||
log("[tmux-session-manager] isolated session teardown", {
|
||||
this.deps.log("[tmux-session-manager] isolated session teardown", {
|
||||
session: isolatedSessionName,
|
||||
killed,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] isolated session teardown failed", {
|
||||
this.deps.log("[tmux-session-manager] isolated session teardown failed", {
|
||||
session: isolatedSessionName,
|
||||
error: String(error),
|
||||
})
|
||||
@@ -1318,7 +1322,7 @@ export class TmuxSessionManager {
|
||||
this.staleSweepCompleted = false
|
||||
this.staleSweepInProgress = false
|
||||
|
||||
log("[tmux-session-manager] cleanup complete")
|
||||
this.deps.log("[tmux-session-manager] cleanup complete")
|
||||
}
|
||||
|
||||
private async sweepStaleIsolatedSessionsOnce(): Promise<void> {
|
||||
@@ -1333,11 +1337,11 @@ export class TmuxSessionManager {
|
||||
try {
|
||||
const killed = await sweepStaleOmoAgentSessions()
|
||||
if (killed > 0) {
|
||||
log("[tmux-session-manager] stale isolated sessions swept", { killed })
|
||||
this.deps.log("[tmux-session-manager] stale isolated sessions swept", { killed })
|
||||
}
|
||||
this.staleSweepCompleted = true
|
||||
} catch (error) {
|
||||
log("[tmux-session-manager] stale sweep failed", {
|
||||
this.deps.log("[tmux-session-manager] stale sweep failed", {
|
||||
error: String(error),
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
|
||||
import type { TmuxCommandResult } from "../../shared/tmux"
|
||||
|
||||
const paneStateQuerierSpecifier = import.meta.resolve("./pane-state-querier")
|
||||
const loggerSpecifier = import.meta.resolve("../../shared")
|
||||
const runnerSpecifier = import.meta.resolve("../../shared/tmux")
|
||||
const tmuxPathResolverSpecifier = import.meta.resolve("../../tools/interactive-bash/tmux-path-resolver")
|
||||
import { queryWindowStateWithDeps } from "./pane-state-querier"
|
||||
|
||||
const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
|
||||
success: true,
|
||||
@@ -14,25 +10,12 @@ const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
}))
|
||||
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
|
||||
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
|
||||
const logMock = mock(() => undefined)
|
||||
|
||||
async function loadQueryWindowState(): Promise<typeof import("./pane-state-querier").queryWindowState> {
|
||||
const module = await import(`${paneStateQuerierSpecifier}?test=${crypto.randomUUID()}`)
|
||||
return module.queryWindowState
|
||||
}
|
||||
|
||||
function registerModuleMocks(): void {
|
||||
mock.module(loggerSpecifier, () => ({ log: logMock }))
|
||||
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
|
||||
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
|
||||
}
|
||||
|
||||
describe("queryWindowState runner integration", () => {
|
||||
beforeEach(() => {
|
||||
mock.restore()
|
||||
registerModuleMocks()
|
||||
runTmuxCommandMock.mockClear()
|
||||
beforeEach(() => {
|
||||
runTmuxCommandMock.mockClear()
|
||||
getTmuxPathMock.mockClear()
|
||||
logMock.mockClear()
|
||||
|
||||
@@ -43,15 +26,16 @@ describe("queryWindowState runner integration", () => {
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
})
|
||||
getTmuxPathMock.mockResolvedValue("sh")
|
||||
})
|
||||
getTmuxPathMock.mockResolvedValue("sh")
|
||||
})
|
||||
|
||||
it("#given source pane id #when queryWindowState called #then delegates list-panes to shared runner", async () => {
|
||||
// given
|
||||
const queryWindowState = await loadQueryWindowState()
|
||||
|
||||
// when
|
||||
const result = await queryWindowState("%0")
|
||||
const result = await queryWindowStateWithDeps("%0", {
|
||||
getTmuxPath: getTmuxPathMock,
|
||||
runTmuxCommand: runTmuxCommandMock,
|
||||
log: logMock,
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result).not.toBeNull()
|
||||
|
||||
@@ -2,13 +2,19 @@ import type { WindowState, TmuxPaneInfo } from "./types"
|
||||
import { parsePaneStateOutput } from "./pane-state-parser"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { log } from "../../shared"
|
||||
import type { TmuxCommandResult } from "../../shared/tmux"
|
||||
|
||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||
const tmux = await getTmuxPath()
|
||||
type QueryWindowStateDeps = {
|
||||
getTmuxPath: typeof getTmuxPath
|
||||
runTmuxCommand: (tmuxPath: string, args: string[]) => Promise<TmuxCommandResult>
|
||||
log: typeof log
|
||||
}
|
||||
|
||||
export async function queryWindowStateWithDeps(sourcePaneId: string, deps: QueryWindowStateDeps): Promise<WindowState | null> {
|
||||
const tmux = await deps.getTmuxPath()
|
||||
if (!tmux) return null
|
||||
const { runTmuxCommand } = await import("../../shared/tmux")
|
||||
|
||||
const result = await runTmuxCommand(tmux, [
|
||||
const result = await deps.runTmuxCommand(tmux, [
|
||||
"list-panes",
|
||||
"-t",
|
||||
sourcePaneId,
|
||||
@@ -17,13 +23,13 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
||||
])
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
log("[pane-state-querier] list-panes failed", { exitCode: result.exitCode })
|
||||
deps.log("[pane-state-querier] list-panes failed", { exitCode: result.exitCode })
|
||||
return null
|
||||
}
|
||||
|
||||
const parsedPaneState = parsePaneStateOutput(result.output)
|
||||
if (!parsedPaneState) {
|
||||
log("[pane-state-querier] failed to parse pane state output", {
|
||||
deps.log("[pane-state-querier] failed to parse pane state output", {
|
||||
sourcePaneId,
|
||||
})
|
||||
return null
|
||||
@@ -49,7 +55,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
||||
return pane.paneId === sourcePaneId ? pane : selected
|
||||
}, null)
|
||||
if (!mainPane) {
|
||||
log("[pane-state-querier] CRITICAL: failed to determine main pane", {
|
||||
deps.log("[pane-state-querier] CRITICAL: failed to determine main pane", {
|
||||
sourcePaneId,
|
||||
availablePanes: panes.map((p) => p.paneId),
|
||||
})
|
||||
@@ -58,7 +64,7 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
||||
|
||||
const agentPanes = panes.filter((p) => p.paneId !== mainPane.paneId)
|
||||
|
||||
log("[pane-state-querier] window state", {
|
||||
deps.log("[pane-state-querier] window state", {
|
||||
windowWidth,
|
||||
windowHeight,
|
||||
mainPane: mainPane.paneId,
|
||||
@@ -67,3 +73,8 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
|
||||
|
||||
return { windowWidth, windowHeight, mainPane, agentPanes }
|
||||
}
|
||||
|
||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||
const { runTmuxCommand } = await import("../../shared/tmux")
|
||||
return queryWindowStateWithDeps(sourcePaneId, { getTmuxPath, runTmuxCommand, log })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user