fix full-suite isolation regressions

This commit is contained in:
YeonGyu-Kim
2026-05-07 17:47:53 +09:00
parent 102b5f96e7
commit ee938aa097
62 changed files with 1238 additions and 899 deletions
@@ -1,18 +1,8 @@
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import * as os from "node:os"
import { tmpdir } from "node:os"
import { join } from "node:path"
const originalHomedir = os.homedir.bind(os)
let mockedHomeDir = ""
let moduleImportCounter = 0
let resolvePromptAppend: typeof import("./resolve-file-uri").resolvePromptAppend
mock.module("node:os", () => ({
...os,
homedir: () => mockedHomeDir || originalHomedir(),
}))
import { resolvePromptAppend } from "./resolve-file-uri"
describe("resolvePromptAppend", () => {
const fixtureRoot = join(tmpdir(), `resolve-file-uri-${Date.now()}`)
@@ -27,8 +17,7 @@ describe("resolvePromptAppend", () => {
const escapedFilePath = join(fixtureRoot, "escaped.txt")
const linkedAbsolutePath = join(configDir, "linked-absolute.txt")
beforeAll(async () => {
mockedHomeDir = homeFixtureRoot
beforeAll(() => {
mkdirSync(fixtureRoot, { recursive: true })
mkdirSync(configDir, { recursive: true })
mkdirSync(homeFixtureDir, { recursive: true })
@@ -39,14 +28,10 @@ describe("resolvePromptAppend", () => {
writeFileSync(homeFilePath, "home-content", "utf8")
writeFileSync(escapedFilePath, "escaped-content", "utf8")
symlinkSync(absoluteFilePath, linkedAbsolutePath)
moduleImportCounter += 1
;({ resolvePromptAppend } = await import(`./resolve-file-uri?test=${moduleImportCounter}`))
})
afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true })
mock.restore()
})
test("returns non-file URI strings unchanged", () => {
+118 -116
View File
@@ -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>
+4 -1
View File
@@ -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"
+4 -4
View File
@@ -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 })
+47 -33
View File
@@ -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"))
+56 -19
View File
@@ -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({
+27 -9
View File
@@ -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),
+5 -14
View File
@@ -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()))
+20 -5
View File
@@ -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
})),
)
+9 -9
View File
@@ -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"))
+31 -12
View File
@@ -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 })
},
})
+43 -25
View File
@@ -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()
})
+75 -71
View File
@@ -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 })
}
@@ -35,19 +35,7 @@ mock.module("../../features/hook-message-injector", () => ({
findNearestMessageWithFields: findNearestMessageWithFieldsMock,
}))
const sessionAgentMap = new Map<string, string>()
const resolveRegisteredAgentNameMock = mock((name: string | undefined) => name)
mock.module("../../features/claude-code-session-state/state", () => ({
_resetForTesting: () => { sessionAgentMap.clear() },
setSessionAgent: (sessionID: string, agent: string) => { sessionAgentMap.set(sessionID, agent) },
getSessionAgent: (sessionID: string) => sessionAgentMap.get(sessionID),
resolveRegisteredAgentName: resolveRegisteredAgentNameMock,
registerAgentName: () => {},
isAgentRegistered: () => false,
resolveInheritedPromptTools: () => undefined,
}))
import { _resetForTesting as resetSessionState, updateSessionAgent } from "../../features/claude-code-session-state/state"
import { runAggressiveTruncationStrategy } from "./aggressive-truncation-strategy"
type FakeClient = {
@@ -89,25 +77,23 @@ async function flushDeferredPrompt(): Promise<void> {
describe("runAggressiveTruncationStrategy - pins agent/model/variant on recovered promptAsync", () => {
beforeEach(() => {
sessionAgentMap.clear()
resetSessionState()
truncateUntilTargetTokensMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockClear()
findNearestMessageWithFieldsMock.mockClear()
resolveRegisteredAgentNameMock.mockClear()
findNearestMessageWithFieldsFromSDKMock.mockResolvedValue(null)
findNearestMessageWithFieldsMock.mockReturnValue(null)
resolveRegisteredAgentNameMock.mockImplementation((name: string | undefined) => name)
})
afterEach(() => {
sessionAgentMap.clear()
resetSessionState()
})
test("includes the session's resolved agent on promptAsync when agent is known", async () => {
// given
const { client, calls } = createRecordingClient()
const sessionID = "session-truncation-agent"
sessionAgentMap.set(sessionID, "sisyphus-junior")
updateSessionAgent(sessionID, "sisyphus-junior")
// when
await runAggressiveTruncationStrategy({
+14 -2
View File
@@ -21,7 +21,19 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) {
return {
handler: createAtlasEventHandler({ ctx, options, sessions, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths, pendingTaskRefs }),
"tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState }),
"tool.execute.before": createToolExecuteBeforeHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
"tool.execute.after": createToolExecuteAfterHandler({
ctx,
pendingFilePaths,
pendingTaskRefs,
autoCommit,
getState,
isCallerOrchestrator: options?.isCallerOrchestrator,
}),
}
}
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test, afterAll } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
import { randomUUID } from "node:crypto"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
@@ -7,32 +7,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk"
import type { AssistantMessage, Session } from "@opencode-ai/sdk"
import type { BoulderState } from "../../features/boulder-state"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-final-wave-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directoryPath = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(directoryPath) ? directoryPath : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
import { createAtlasHook } from "./index"
type AtlasHookContext = Parameters<typeof createAtlasHook>[0]
type PromptMock = ReturnType<typeof mock>
@@ -89,28 +64,6 @@ describe("Atlas final verification approval gate", () => {
}
}
function setupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDirectory)) {
mkdirSync(messageDirectory, { recursive: true })
}
writeFileSync(
join(messageDirectory, "msg_test001.json"),
JSON.stringify({
agent: "atlas",
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}),
)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDirectory = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDirectory)) {
rmSync(messageDirectory, { recursive: true, force: true })
}
}
beforeEach(() => {
testDirectory = join(tmpdir(), `atlas-final-wave-test-${randomUUID()}`)
mkdirSync(join(testDirectory, ".sisyphus"), { recursive: true })
@@ -127,7 +80,6 @@ describe("Atlas final verification approval gate", () => {
test("waits for explicit user approval after the last final-wave approval arrives", async () => {
// given
const sessionID = "atlas-final-wave-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "final-wave-plan.md")
writeFileSync(
@@ -155,7 +107,7 @@ describe("Atlas final verification approval gate", () => {
writeBoulderState(testDirectory, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createAtlasHook(mockInput, { directory: testDirectory, isCallerOrchestrator: async () => true })
const toolOutput = {
title: "Sisyphus Task",
output: `Tasks [4/4 compliant] | Contamination [CLEAN] | Unaccounted [CLEAN] | VERDICT: APPROVE
@@ -176,13 +128,11 @@ session_id: ses_final_wave_review
expect(toolOutput.output).not.toContain("STEP 8: PROCEED TO NEXT TASK")
expect(mockInput._promptMock).not.toHaveBeenCalled()
cleanupMessageStorage(sessionID)
})
test("keeps normal auto-continue instructions for non-final tasks", async () => {
// given
const sessionID = "atlas-non-final-session"
setupMessageStorage(sessionID)
const planPath = join(testDirectory, "implementation-plan.md")
writeFileSync(
@@ -210,7 +160,10 @@ session_id: ses_final_wave_review
}
writeBoulderState(testDirectory, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createAtlasHook(createMockPluginInput(), {
directory: testDirectory,
isCallerOrchestrator: async () => true,
})
const toolOutput = {
title: "Sisyphus Task",
output: `Implementation finished successfully
@@ -229,6 +182,5 @@ session_id: ses_feature_task
expect(toolOutput.output).toContain("STEP 8: PROCEED TO NEXT TASK")
expect(toolOutput.output).not.toContain("FINAL WAVE APPROVAL GATE")
cleanupMessageStorage(sessionID)
})
})
+108 -119
View File
@@ -1,8 +1,9 @@
import { describe, expect, test, beforeEach, afterEach, mock, afterAll } from "bun:test"
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { randomUUID } from "node:crypto"
import { createOpencodeClient } from "@opencode-ai/sdk"
import {
writeBoulderState,
clearBoulderState,
@@ -10,35 +11,16 @@ import {
} from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types"
import type { AtlasHookOptions, PendingTaskRef } from "./types"
import { createAtlasHook } from "./index"
import { createToolExecuteAfterHandler } from "./tool-execute-after"
import { createToolExecuteBeforeHandler } from "./tool-execute-before"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
const TEST_PART_STORAGE = join(TEST_STORAGE_ROOT, "part")
mock.module("../../features/hook-message-injector/constants", () => ({
OPENCODE_STORAGE: TEST_STORAGE_ROOT,
MESSAGE_STORAGE: TEST_MESSAGE_STORAGE,
PART_STORAGE: TEST_PART_STORAGE,
}))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const dir = join(TEST_MESSAGE_STORAGE, sessionID)
return existsSync(dir) ? dir : null
},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
afterAll(() => { mock.restore() })
const { createAtlasHook } = await import("./index")
const { createToolExecuteAfterHandler } = await import("./tool-execute-after")
const { createToolExecuteBeforeHandler } = await import("./tool-execute-before")
const { MESSAGE_STORAGE } = await import("../../features/hook-message-injector")
const callerAgentBySession = new Map<string, string>()
type MockAtlasInput = Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
describe("atlas hook", () => {
let TEST_DIR: string
@@ -47,7 +29,7 @@ describe("atlas hook", () => {
function createMockPluginInput(overrides?: {
promptMock?: ReturnType<typeof mock>
sessionGetMock?: ReturnType<typeof mock>
}) {
}): MockAtlasInput {
const promptMock = overrides?.promptMock ?? mock(() => Promise.resolve())
const sessionGetMock = overrides?.sessionGetMock ?? mock(async ({ path }: { path: { id: string } }) => ({
data: {
@@ -55,40 +37,41 @@ describe("atlas hook", () => {
parentID: path.id.startsWith("ses_") ? "session-1" : "main-session-123",
},
}))
const client = createOpencodeClient({ baseUrl: "http://localhost" })
Reflect.set(client.session, "get", sessionGetMock)
Reflect.set(client.session, "prompt", promptMock)
Reflect.set(client.session, "promptAsync", promptMock)
return {
directory: TEST_DIR,
client: {
session: {
get: sessionGetMock,
prompt: promptMock,
promptAsync: promptMock,
},
},
project: {} as Parameters<typeof createAtlasHook>[0]["project"],
worktree: TEST_DIR,
serverUrl: new URL("http://localhost"),
$: {} as Parameters<typeof createAtlasHook>[0]["$"],
client,
_promptMock: promptMock,
_sessionGetMock: sessionGetMock,
} as Parameters<typeof createAtlasHook>[0] & {
_promptMock: ReturnType<typeof mock>
_sessionGetMock: ReturnType<typeof mock>
}
}
function setupMessageStorage(sessionID: string, agent: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (!existsSync(messageDir)) {
mkdirSync(messageDir, { recursive: true })
}
const messageData = {
agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-7" },
}
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
callerAgentBySession.set(sessionID, agent)
}
function cleanupMessageStorage(sessionID: string): void {
const messageDir = join(MESSAGE_STORAGE, sessionID)
if (existsSync(messageDir)) {
rmSync(messageDir, { recursive: true, force: true })
callerAgentBySession.delete(sessionID)
}
function createTestAtlasHook(
input = createMockPluginInput(),
options: Partial<AtlasHookOptions> = {},
): ReturnType<typeof createAtlasHook> {
const resolvedOptions: AtlasHookOptions = {
directory: TEST_DIR,
isCallerOrchestrator: async (sessionID) => callerAgentBySession.get(sessionID ?? "") === "atlas",
...options,
}
return createAtlasHook(input, resolvedOptions)
}
beforeEach(() => {
@@ -104,10 +87,12 @@ describe("atlas hook", () => {
mkdirSync(SISYPHUS_DIR, { recursive: true })
}
clearBoulderState(TEST_DIR)
callerAgentBySession.clear()
})
afterEach(() => {
_resetForTesting()
callerAgentBySession.clear()
clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
@@ -117,7 +102,7 @@ describe("atlas hook", () => {
describe("tool.execute.after handler", () => {
test("should handle undefined output gracefully (issue #1035)", async () => {
// given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - calling with undefined output
const result = await hook["tool.execute.after"](
@@ -131,7 +116,7 @@ describe("atlas hook", () => {
test("should ignore non-task tools", async () => {
// given - hook and non-task tool
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Test Tool",
output: "Original output",
@@ -164,7 +149,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -188,7 +173,7 @@ describe("atlas hook", () => {
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -225,7 +210,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -264,7 +249,7 @@ describe("atlas hook", () => {
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -301,7 +286,7 @@ session_id: ses_subagent_abc
const sessionID = "session-standalone-metadata-test"
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed
@@ -349,7 +334,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Original output",
@@ -386,7 +371,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -422,7 +407,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async () => {
throw new Error("session lookup failed")
}),
@@ -462,7 +447,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task output",
@@ -499,7 +484,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -536,7 +521,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed",
@@ -581,6 +566,7 @@ session_id: ses_standalone_def
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -588,6 +574,7 @@ session_id: ses_standalone_def
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - the task is captured before execution
@@ -634,7 +621,7 @@ session_id: ses_standalone_def
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task completed successfully
@@ -684,7 +671,7 @@ session_id: ses_auth_flow_123
plan_name: "stable-task-key-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas delegates task 1
await hook["tool.execute.before"](
@@ -744,7 +731,7 @@ session_id: ses_auth_flow_123
plan_name: "cross-task-resume-plan",
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
// when - Atlas resumes an explicit prior session
await hook["tool.execute.before"](
@@ -806,7 +793,7 @@ session_id: ses_old_task_111
},
})
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: `Task continued successfully
@@ -860,6 +847,7 @@ session_id: ses_old_task_111
ctx: createMockPluginInput(),
pendingFilePaths,
pendingTaskRefs,
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
const afterHandler = createToolExecuteAfterHandler({
ctx: createMockPluginInput(),
@@ -867,6 +855,7 @@ session_id: ses_old_task_111
pendingTaskRefs,
autoCommit: true,
getState: () => ({ promptFailureCount: 0 }),
isCallerOrchestrator: async (id) => callerAgentBySession.get(id ?? "") === "atlas",
})
// when - two task() calls start before either one completes
@@ -929,7 +918,7 @@ session_id: ses_parallel_collision_222
plan_name: "untrusted-session-id-plan",
})
const hook = createAtlasHook(createMockPluginInput({
const hook = createTestAtlasHook(createMockPluginInput({
sessionGetMock: mock(async ({ path }: { path: { id: string } }) => ({
data: {
id: path.id,
@@ -987,7 +976,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1022,7 +1011,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1061,7 +1050,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Sisyphus Task",
output: "Task completed successfully",
@@ -1093,7 +1082,7 @@ session_id: ses_untrusted_999
test("should append delegation reminder when orchestrator writes outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1114,7 +1103,7 @@ session_id: ses_untrusted_999
test("should append delegation reminder when orchestrator edits outside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Edit",
output: "File edited successfully",
@@ -1133,7 +1122,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder when orchestrator writes inside .sisyphus/", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1157,7 +1146,7 @@ session_id: ses_untrusted_999
const nonOrchestratorSession = "non-orchestrator-session"
setupMessageStorage(nonOrchestratorSession, "sisyphus-junior")
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1180,7 +1169,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder for read-only tools", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File content"
const output = {
title: "Read",
@@ -1200,7 +1189,7 @@ session_id: ses_untrusted_999
test("should handle missing filePath gracefully", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1221,7 +1210,7 @@ session_id: ses_untrusted_999
describe("cross-platform path validation (Windows support)", () => {
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1242,7 +1231,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1263,7 +1252,7 @@ session_id: ses_untrusted_999
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const originalOutput = "File written successfully"
const output = {
title: "Write",
@@ -1284,7 +1273,7 @@ session_id: ses_untrusted_999
test("should append reminder for Windows path outside .sisyphus\\", async () => {
// given
const hook = createAtlasHook(createMockPluginInput())
const hook = createTestAtlasHook(createMockPluginInput())
const output = {
title: "Write",
output: "File written successfully",
@@ -1339,7 +1328,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1360,7 +1349,7 @@ session_id: ses_untrusted_999
test("should not inject when no boulder state exists", async () => {
// given - no boulder state
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1388,7 +1377,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - main session fires idle but is NOT in boulder's session_ids
await hook.handler({
@@ -1419,7 +1408,7 @@ session_id: ses_untrusted_999
updateSessionAgent(subagentSessionID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - subagent session goes idle before explicit tracking appends it
await hook.handler({
@@ -1451,7 +1440,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1480,7 +1469,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1513,7 +1502,7 @@ session_id: ses_untrusted_999
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
try {
// when
@@ -1545,7 +1534,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - send abort error then idle
await hook.handler({
@@ -1582,7 +1571,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - a recoverable runtime error fires without waiting for idle
await hook.handler({
@@ -1618,14 +1607,14 @@ session_id: ses_untrusted_999
const originalSetTimeout = globalThis.setTimeout
const scheduledDelays: number[] = []
globalThis.setTimeout = ((_handler: TimerHandler, timeout?: number, ..._args: unknown[]) => {
globalThis.setTimeout = ((_handler: Parameters<typeof setTimeout>[0], timeout?: number, ..._args: unknown[]) => {
scheduledDelays.push(timeout ?? 0)
return originalSetTimeout(() => undefined, 0)
}) as typeof setTimeout
try {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and OpenCode later emits stale idle
await hook.handler({
@@ -1671,7 +1660,7 @@ session_id: ses_untrusted_999
try {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - runtime error resumes immediately and then the retry run emits assistant activity
await hook.handler({
@@ -1722,7 +1711,7 @@ session_id: ses_untrusted_999
}
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
backgroundManager: mockBackgroundManager,
})
@@ -1753,7 +1742,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput, {
const hook = createTestAtlasHook(mockInput, {
directory: TEST_DIR,
isContinuationStopped: (sessionID: string) => sessionID === MAIN_SESSION_ID,
})
@@ -1784,7 +1773,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - abort error, then message update, then idle
await hook.handler({
@@ -1827,7 +1816,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1869,7 +1858,7 @@ session_id: ses_untrusted_999
})
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1904,7 +1893,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1935,7 +1924,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: {
@@ -1965,7 +1954,7 @@ session_id: ses_untrusted_999
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -1997,7 +1986,7 @@ session_id: ses_untrusted_999
registerAgentName("Atlas - Plan Executor")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -2028,7 +2017,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - fire multiple idle events in rapid succession (simulating infinite loop bug)
await hook.handler({
@@ -2069,7 +2058,7 @@ session_id: ses_untrusted_999
const promptMock = mock((): Promise<void> => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2111,7 +2100,7 @@ session_id: ses_untrusted_999
promptMock.mockImplementationOnce(() => Promise.resolve())
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2147,7 +2136,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2188,7 +2177,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2233,7 +2222,7 @@ session_id: ses_untrusted_999
}
promptMock.mockImplementationOnce(() => Promise.resolve(undefined))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2284,7 +2273,7 @@ session_id: ses_untrusted_999
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
@@ -2328,7 +2317,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - create abort state then delete
await hook.handler({
@@ -2381,7 +2370,7 @@ session_id: ses_untrusted_999
updateSessionAgent(MAIN_SESSION_ID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when
await hook.handler({
@@ -2407,7 +2396,7 @@ session_id: ses_untrusted_999
fakeNow = 10000
Date.now = () => fakeNow
globalThis.setTimeout = ((callback: TimerHandler, delay?: number, ...args: unknown[]) => {
globalThis.setTimeout = ((callback: Parameters<typeof setTimeout>[0], delay?: number, ...args: unknown[]) => {
const normalized = typeof delay === "number" ? delay : 0
if (normalized >= 5000) {
const timerID = originalSetTimeout(() => undefined, 0)
@@ -2463,7 +2452,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second idle within cooldown schedules retry timer
await hook.handler({
@@ -2492,7 +2481,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, then 3 rapid idles within cooldown
await hook.handler({
@@ -2527,7 +2516,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
// when - first idle injects, second schedules retry, then plan completes before timer fires
await hook.handler({
@@ -2558,7 +2547,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -2591,7 +2580,7 @@ session_id: ses_untrusted_999
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
const hook = createTestAtlasHook(mockInput)
await hook.handler({
event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } },
@@ -1,26 +1,30 @@
declare const require: (name: string) => any
const { describe, expect, mock, test, afterAll } = require("bun:test")
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { afterAll, describe, expect, test } from "bun:test"
import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
import type { ModelInfo } from "./types"
const testDirs: string[] = []
const TEST_STORAGE_ROOT = join(tmpdir(), `recent-model-fallback-${Date.now()}`)
const TEST_MESSAGE_STORAGE = join(TEST_STORAGE_ROOT, "message")
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => false,
}))
function findNearestTestMessage(messageDir: string): { model?: ModelInfo; tools?: Record<string, boolean> } | null {
const [message] = readdirSync(messageDir)
.filter((fileName) => fileName.endsWith(".json"))
.map((fileName) => {
const content = readFileSync(join(messageDir, fileName), "utf-8")
const parsed = JSON.parse(content) as { model?: ModelInfo; tools?: Record<string, boolean>; time?: { created?: number } }
return {
message: parsed,
createdAt: parsed.time?.created ?? Number.NEGATIVE_INFINITY,
fileName,
}
})
.sort((left, right) => right.createdAt - left.createdAt || right.fileName.localeCompare(left.fileName))
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: (sessionID: string) => {
const directPath = join(TEST_MESSAGE_STORAGE, sessionID)
return require("node:fs").existsSync(directPath) ? directPath : null
},
}))
return message?.message ?? null
}
afterAll(() => {
mock.restore()
while (testDirs.length > 0) {
const directory = testDirs.pop()
if (directory) {
@@ -34,8 +38,10 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
// given
const sessionID = "ses_recent_model_fallback"
const directory = mkdtempSync(join(tmpdir(), "recent-model-fallback-dir-"))
const storageRoot = mkdtempSync(join(tmpdir(), "recent-model-fallback-storage-"))
testDirs.push(directory)
const messageDir = join(TEST_MESSAGE_STORAGE, sessionID)
testDirs.push(storageRoot)
const messageDir = join(storageRoot, sessionID)
mkdirSync(messageDir, { recursive: true })
writeFileSync(join(messageDir, "msg_ffff0000_000001.json"), JSON.stringify({
agent: "atlas",
@@ -50,8 +56,6 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
time: { created: 100 },
}), "utf-8")
const { resolveRecentPromptContextForSession } = await import("./recent-model-resolver")
const ctx = {
client: {
session: {
@@ -63,7 +67,12 @@ describe("resolveRecentPromptContextForSession fallback ordering", () => {
}
// when
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID)
const result = await resolveRecentPromptContextForSession(ctx as never, sessionID, {
isSqliteBackend: () => false,
getMessageDir: () => messageDir,
findNearestMessageWithFields: findNearestTestMessage,
findNearestMessageWithFieldsFromSDK: async () => null,
})
// then
expect(result.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
+20 -5
View File
@@ -11,9 +11,24 @@ type PromptContext = {
tools?: Record<string, boolean>
}
type RecentPromptContextDeps = {
isSqliteBackend: typeof isSqliteBackend
getMessageDir: typeof getMessageDir
findNearestMessageWithFields: typeof findNearestMessageWithFields
findNearestMessageWithFieldsFromSDK: typeof findNearestMessageWithFieldsFromSDK
}
const defaultDeps: RecentPromptContextDeps = {
isSqliteBackend,
getMessageDir,
findNearestMessageWithFields,
findNearestMessageWithFieldsFromSDK,
}
export async function resolveRecentPromptContextForSession(
ctx: PluginInput,
sessionID: string
sessionID: string,
deps: RecentPromptContextDeps = defaultDeps,
): Promise<PromptContext> {
try {
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
@@ -59,11 +74,11 @@ export async function resolveRecentPromptContextForSession(
}
let currentMessage = null
if (isSqliteBackend()) {
currentMessage = await findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
if (deps.isSqliteBackend()) {
currentMessage = await deps.findNearestMessageWithFieldsFromSDK(ctx.client, sessionID)
} else {
const messageDir = getMessageDir(sessionID)
currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const messageDir = deps.getMessageDir(sessionID)
currentMessage = messageDir ? deps.findNearestMessageWithFields(messageDir) : null
}
const model = currentMessage?.model
const tools = normalizePromptTools(currentMessage?.tools)
+3 -2
View File
@@ -1,6 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import {
appendSessionId,
getPlanProgress,
getTaskSessionState,
readBoulderState,
@@ -33,15 +32,17 @@ export function createToolExecuteAfterHandler(input: {
pendingTaskRefs: Map<string, PendingTaskRef>
autoCommit: boolean
getState: (sessionID: string) => SessionState
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput | undefined) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs, autoCommit, getState } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
return async (toolInput, toolOutput): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!toolOutput) {
return
}
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
+3 -1
View File
@@ -13,18 +13,20 @@ export function createToolExecuteBeforeHandler(input: {
ctx: PluginInput
pendingFilePaths: Map<string, string>
pendingTaskRefs: Map<string, PendingTaskRef>
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
}): (
toolInput: { tool: string; sessionID?: string; callID?: string },
toolOutput: { args: Record<string, unknown>; message?: string }
) => Promise<void> {
const { ctx, pendingFilePaths, pendingTaskRefs } = input
const resolveIsCallerOrchestrator = input.isCallerOrchestrator ?? ((sessionID) => isCallerOrchestrator(sessionID, ctx.client))
function trackTask(callID: string, task: TrackedTopLevelTaskRef): void {
pendingTaskRefs.set(callID, { kind: "track", task })
}
return async (toolInput, toolOutput): Promise<void> => {
if (!(await isCallerOrchestrator(toolInput.sessionID, ctx.client))) {
if (!(await resolveIsCallerOrchestrator(toolInput.sessionID))) {
return
}
+1
View File
@@ -11,6 +11,7 @@ export interface AtlasHookOptions {
directory: string
backgroundManager?: BackgroundTaskStatusProvider
isContinuationStopped?: (sessionID: string) => boolean
isCallerOrchestrator?: (sessionID: string | undefined) => Promise<boolean>
agentOverrides?: AgentOverrides
/** Enable auto-commit after each atomic task completion (default: true) */
autoCommit?: boolean
+26
View File
@@ -304,6 +304,32 @@ describe("ralph-loop", () => {
expect(state?.iteration).toBe(2)
})
test("#given hanging toast #when session idles #then continuation still injects", async () => {
// given - TUI toast never settles
const ctx = createMockPluginInput()
ctx.client.tui = {
showToast: () => new Promise(() => {}),
} as never
const hook = createRalphLoopHook(ctx)
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// when - session goes idle
const result = await Promise.race([
hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
}).then(() => "resolved" as const),
new Promise<"timed-out">((resolvePromise) => setTimeout(() => resolvePromise("timed-out"), 50)),
])
// then - continuation is not blocked by toast delivery
expect(result).toBe("resolved")
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
})
test("should skip continuation when background task is running", async () => {
// given - active loop state with a running background task
const hook = createRalphLoopHook(createMockPluginInput(), {
@@ -70,27 +70,38 @@ function isAbortError(error: unknown): boolean {
&& (error as { name?: unknown }).name === "MessageAbortedError"
}
async function showMaxIterationsToast(
function showToastBestEffort(
ctx: PluginInput,
state: RalphLoopState,
): Promise<void> {
await ctx.client.tui?.showToast?.({
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
}).catch(() => {})
body: { title: string; message: string; variant: "warning" | "info"; duration: number },
): void {
try {
void Promise.resolve(ctx.client.tui?.showToast?.({ body })).catch(() => {})
} catch {
}
}
async function showIterationToast(
function showMaxIterationsToast(
ctx: PluginInput,
state: RalphLoopState,
): Promise<void> {
await ctx.client.tui?.showToast?.({
body: {
title: "Ralph Loop",
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
variant: "info",
duration: 2000,
},
}).catch(() => {})
): void {
showToastBestEffort(ctx, {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
})
}
function showIterationToast(
ctx: PluginInput,
state: RalphLoopState,
): void {
showToastBestEffort(ctx, {
title: "Ralph Loop",
message: `Iteration ${state.iteration}/${typeof state.max_iterations === "number" ? state.max_iterations : "unbounded"}`,
variant: "info",
duration: 2000,
})
}
export function createRalphLoopEventHandler(
@@ -253,7 +264,7 @@ export function createRalphLoopEventHandler(
})
options.loopState.clear()
await showMaxIterationsToast(ctx, state)
showMaxIterationsToast(ctx, state)
return
}
@@ -269,7 +280,7 @@ export function createRalphLoopEventHandler(
max: newState.max_iterations,
})
await showIterationToast(ctx, newState)
showIterationToast(ctx, newState)
try {
await continueIteration(ctx, newState, {
@@ -361,7 +372,7 @@ export function createRalphLoopEventHandler(
max: state.max_iterations,
})
options.loopState.clear()
await showMaxIterationsToast(ctx, state)
showMaxIterationsToast(ctx, state)
return
}
@@ -371,7 +382,7 @@ export function createRalphLoopEventHandler(
return
}
await showIterationToast(ctx, newState)
showIterationToast(ctx, newState)
try {
await continueIteration(ctx, newState, {
previousSessionID: sessionID,
+10 -5
View File
@@ -3,9 +3,7 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync, realpathSync } from "fs"
import { basename, dirname, isAbsolute, join, normalize, relative, resolve } from "path"
import { log } from "../../shared"
import { handleWriteExistingFileGuardToolExecuteBefore } from "./tool-execute-before-handler"
import { evictLeastRecentlyUsedSession, touchSession, trimSessionReadSet } from "./session-read-permissions"
export type GuardArgs = {
filePath?: string
@@ -16,7 +14,11 @@ export type GuardArgs = {
const MAX_TRACKED_SESSIONS = 256
export const MAX_TRACKED_PATHS_PER_SESSION = 1024
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
type WriteExistingFileGuardOptions = {
maxTrackedSessions?: number
maxTrackedPathsPerSession?: number
}
export function asRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -73,9 +75,11 @@ export function isOverwriteEnabled(value: boolean | string | undefined): boolean
return false
}
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
export function createWriteExistingFileGuardHook(ctx: PluginInput, options?: WriteExistingFileGuardOptions): Hooks {
const readPermissionsBySession = new Map<string, Set<string>>()
const sessionLastAccess = new Map<string, number>()
const maxTrackedSessions = options?.maxTrackedSessions ?? MAX_TRACKED_SESSIONS
const maxTrackedPathsPerSession = options?.maxTrackedPathsPerSession ?? MAX_TRACKED_PATHS_PER_SESSION
let canonicalSessionRoot: string | undefined
function getCanonicalSessionRoot(): string {
@@ -95,7 +99,8 @@ export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
readPermissionsBySession,
sessionLastAccess,
getCanonicalSessionRoot,
maxTrackedSessions: MAX_TRACKED_SESSIONS,
maxTrackedSessions,
maxTrackedPathsPerSession,
})
},
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -3,7 +3,6 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync
import { tmpdir } from "node:os"
import { dirname, join, resolve } from "node:path"
import { MAX_TRACKED_PATHS_PER_SESSION } from "./hook"
import { createWriteExistingFileGuardHook } from "./index"
const BLOCK_MESSAGE = "File already exists. Use edit tool instead."
@@ -56,7 +55,7 @@ describe("createWriteExistingFileGuardHook", () => {
}
const emitSessionDeleted = async (sessionID: string): Promise<void> => {
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } } as never)
}
beforeEach(() => {
@@ -432,6 +431,11 @@ describe("createWriteExistingFileGuardHook", () => {
test("#given session reads beyond path cap #when writing oldest and newest #then only newest is authorized", async () => {
const sessionID = "ses_path_cap"
const maxTrackedPathsPerSession = 4
hook = createWriteExistingFileGuardHook(
{ directory: tempDir } as never,
{ maxTrackedPathsPerSession },
)
const oldestFile = createFile("path-cap/0.txt")
let newestFile = oldestFile
@@ -441,7 +445,7 @@ describe("createWriteExistingFileGuardHook", () => {
outputArgs: { filePath: oldestFile },
})
for (let index = 1; index <= MAX_TRACKED_PATHS_PER_SESSION; index += 1) {
for (let index = 1; index <= maxTrackedPathsPerSession; index += 1) {
newestFile = createFile(`path-cap/${index}.txt`)
await invoke({
tool: "read",
@@ -5,37 +5,35 @@ import { join } from "node:path"
const realFs = await import("node:fs")
const existsSyncMock = mock(realFs.existsSync)
const realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import("./index")
describe("createWriteExistingFileGuardHook", () => {
let tempDir = ""
let existsSyncMock: ReturnType<typeof mock<typeof realFs.existsSync>>
let realpathNativeMock: ReturnType<typeof mock<typeof realFs.realpathSync.native>>
beforeEach(() => {
// given
tempDir = mkdtempSync(join(tmpdir(), "write-existing-file-guard-lazy-"))
mkdirSync(tempDir, { recursive: true })
existsSyncMock.mockClear()
realpathNativeMock.mockClear()
})
afterEach(() => {
mock.restore()
rmSync(tempDir, { recursive: true, force: true })
})
test("#given hook factory #when created #then defers fs canonical path calls until first tool invocation", async () => {
// given
existsSyncMock = mock(realFs.existsSync)
realpathNativeMock = mock(realFs.realpathSync.native)
mock.module("fs", () => ({
...realFs,
existsSync: existsSyncMock,
realpathSync: {
...realFs.realpathSync,
native: realpathNativeMock,
},
}))
const { createWriteExistingFileGuardHook } = await import(`./hook?test=${crypto.randomUUID()}`)
const existingFile = join(tempDir, "existing.txt")
writeFileSync(existingFile, "content")
@@ -44,6 +44,7 @@ function registerReadPermission(params: {
readPermissionsBySession: Map<string, Set<string>>
sessionLastAccess: Map<string, number>
maxTrackedSessions: number
maxTrackedPathsPerSession: number
}): void {
const readSet = ensureSessionReadSet(params)
if (readSet.has(params.canonicalPath)) {
@@ -51,7 +52,7 @@ function registerReadPermission(params: {
}
readSet.add(params.canonicalPath)
trimSessionReadSet(readSet, MAX_TRACKED_PATHS_PER_SESSION)
trimSessionReadSet(readSet, params.maxTrackedPathsPerSession)
}
function consumeReadPermission(params: {
@@ -92,8 +93,18 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
sessionLastAccess: Map<string, number>
getCanonicalSessionRoot: () => string
maxTrackedSessions: number
maxTrackedPathsPerSession?: number
}): Promise<void> {
const { ctx, input, output, readPermissionsBySession, sessionLastAccess, getCanonicalSessionRoot, maxTrackedSessions } = params
const {
ctx,
input,
output,
readPermissionsBySession,
sessionLastAccess,
getCanonicalSessionRoot,
maxTrackedSessions,
maxTrackedPathsPerSession = MAX_TRACKED_PATHS_PER_SESSION,
} = params
const toolName = input.tool?.toLowerCase()
if (toolName !== "write" && toolName !== "read") {
return
@@ -124,6 +135,7 @@ export async function handleWriteExistingFileGuardToolExecuteBefore(params: {
readPermissionsBySession,
sessionLastAccess,
maxTrackedSessions,
maxTrackedPathsPerSession,
})
return
}
+1 -1
View File
@@ -259,7 +259,7 @@ describe("createEventHandler - idle deduplication", () => {
sweepStaleOmoAgentSessions: async () => 0,
}))
const { TmuxSessionManager } = await import("../features/tmux-subagent/manager")
const { TmuxSessionManager } = await import(`../features/tmux-subagent/manager?test=${crypto.randomUUID()}`)
const managerContext = asPluginInput({
serverUrl: new URL("http://localhost:4096"),
directory: "/tmp",
+25 -26
View File
@@ -65,10 +65,10 @@ describe("posthog client creation", () => {
// then
expect(() => cliPostHog.trackActive("cli", "run_started")).not.toThrow()
await expect(cliPostHog.shutdown()).resolves.toBeUndefined()
expect(await cliPostHog.shutdown()).toBeUndefined()
expect(() => pluginPostHog.trackActive("plugin", "run_started")).not.toThrow()
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
expect(await pluginPostHog.shutdown()).toBeUndefined()
})
it("creates a plugin client when os.cpus throws", async () => {
@@ -77,20 +77,6 @@ describe("posthog client creation", () => {
process.env.OMO_SEND_ANONYMOUS_TELEMETRY = "1"
process.env.POSTHOG_API_KEY = "test-api-key"
mock.module("os", () => ({
default: {
arch: () => "x64",
cpus: () => {
throw new Error("Failed to get CPU information")
},
hostname: () => "test-host",
platform: () => "linux",
release: () => "6.8.0-arch1-1",
totalmem: () => 8 * 1024 * 1024 * 1024,
type: () => "Linux",
},
}))
mock.module("posthog-node", () => ({
PostHog: class {
capture() {}
@@ -98,14 +84,26 @@ describe("posthog client creation", () => {
},
}))
const { createPluginPostHog } = await importPostHogModule()
const posthogModule = await importPostHogModule()
posthogModule.__setOsProviderForTesting({
arch: () => "x64",
cpus: () => {
throw new Error("Failed to get CPU information")
},
hostname: () => "test-host",
platform: () => "linux",
release: () => "6.8.0-arch1-1",
totalmem: () => 8 * 1024 * 1024 * 1024,
type: () => "Linux",
})
// when
const pluginPostHog = createPluginPostHog()
const pluginPostHog = posthogModule.createPluginPostHog()
// then
expect(() => pluginPostHog.trackActive("plugin", "run_started")).not.toThrow()
await expect(pluginPostHog.shutdown()).resolves.toBeUndefined()
expect(await pluginPostHog.shutdown()).toBeUndefined()
posthogModule.__resetOsProviderForTesting()
})
it("passes the strict PostHog constructor options for both clients", async () => {
@@ -180,15 +178,16 @@ describe("posthog trackActive emission contract", () => {
const emittedEvents = captured.map((message) => message.event)
expect(emittedEvents).not.toContain("omo_hourly_active")
const [dailyEvent] = captured
if (!dailyEvent) {
throw new Error("Expected daily event")
}
expect(dailyEvent?.event).toBe("omo_daily_active")
expect(dailyEvent?.distinctId).toBe("distinct-cli")
expect(dailyEvent?.properties).toMatchObject({
day_utc: "2026-04-18",
reason: "run_started",
source: "cli",
$process_person_profile: false,
})
expect(dailyEvent?.properties).not.toHaveProperty("hour_utc")
expect(dailyEvent.properties?.day_utc).toBe("2026-04-18")
expect(dailyEvent.properties?.reason).toBe("run_started")
expect(dailyEvent.properties?.source).toBe("cli")
expect(dailyEvent.properties?.$process_person_profile).toBe(false)
expect(Object.prototype.hasOwnProperty.call(dailyEvent.properties ?? {}, "hour_utc")).toBe(false)
})
it("emits nothing and never omo_hourly_active when captureDaily is false", async () => {
+24 -7
View File
@@ -7,11 +7,17 @@ import { getPostHogActivityCaptureState } from "./posthog-activity-state"
/** @internal test-only seam: keep null in production to use the real implementation. */
let activityStateProviderOverride: typeof getPostHogActivityCaptureState | null = null
type OsProvider = Pick<typeof os, "arch" | "cpus" | "hostname" | "platform" | "release" | "totalmem" | "type">
let osProviderOverride: OsProvider | null = null
function resolveActivityState(): ReturnType<typeof getPostHogActivityCaptureState> {
return (activityStateProviderOverride ?? getPostHogActivityCaptureState)()
}
function resolveOsProvider(): OsProvider {
return osProviderOverride ?? os
}
/** @internal test-only */
export function __setActivityStateProviderForTesting(
provider: typeof getPostHogActivityCaptureState,
@@ -24,6 +30,16 @@ export function __resetActivityStateProviderForTesting(): void {
activityStateProviderOverride = null
}
/** @internal test-only */
export function __setOsProviderForTesting(provider: OsProvider): void {
osProviderOverride = provider
}
/** @internal test-only */
export function __resetOsProviderForTesting(): void {
osProviderOverride = null
}
const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"
const DEFAULT_POSTHOG_API_KEY = "phc_CFJhj5HyvA62QPhvyaUCtaq23aUfznnijg5VaaGkNk74"
@@ -67,7 +83,7 @@ function getPostHogHost(): string {
function safeCpus(): { length: number; model: string | undefined } {
try {
const cpus = os.cpus()
const cpus = resolveOsProvider().cpus()
return { length: cpus.length, model: cpus[0]?.model }
} catch {
return { length: 0, model: undefined }
@@ -76,6 +92,7 @@ function safeCpus(): { length: number; model: string | undefined } {
function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureEvent["properties"]> {
const cpus = safeCpus()
const osProvider = resolveOsProvider()
return {
platform: "oh-my-opencode",
@@ -85,13 +102,13 @@ function getSharedProperties(source: PostHogSource): NonNullable<PostHogCaptureE
runtime: "bun",
runtime_version: process.versions.bun ?? process.version,
source,
$os: os.platform(),
$os_version: os.release(),
os_arch: os.arch(),
os_type: os.type(),
$os: osProvider.platform(),
$os_version: osProvider.release(),
os_arch: osProvider.arch(),
os_type: osProvider.type(),
cpu_count: cpus.length,
cpu_model: cpus.model,
total_memory_gb: Math.round(os.totalmem() / 1024 / 1024 / 1024),
total_memory_gb: Math.round(osProvider.totalmem() / 1024 / 1024 / 1024),
locale: Intl.DateTimeFormat().resolvedOptions().locale,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
shell: process.env.SHELL,
@@ -144,7 +161,7 @@ function createPostHogClient(
export function getPostHogDistinctId(): string {
return createHash("sha256")
.update(`${PUBLISHED_PACKAGE_NAME}:${os.hostname()}`)
.update(`${PUBLISHED_PACKAGE_NAME}:${resolveOsProvider().hostname()}`)
.digest("hex")
}
@@ -1,14 +1,11 @@
/// <reference types="bun-types" />
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const paneSpawnSpecifier = import.meta.resolve("./pane-spawn")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const serverHealthSpecifier = import.meta.resolve("./server-health")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const enabledTmuxConfig = {
enabled: true,
@@ -28,7 +25,7 @@ const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
@@ -64,23 +61,24 @@ function getSplitWindowCommand(): string {
return splitCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./pane-spawn").spawnTmuxPane>[7]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxPane(): Promise<typeof import("./pane-spawn").spawnTmuxPane> {
const module = await import(`${paneSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxPane
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(serverHealthSpecifier, () => ({ isServerRunning: isServerRunningMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("spawnTmuxPane runner integration", () => {
beforeEach(() => {
mock.restore()
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
@@ -109,7 +107,7 @@ describe("spawnTmuxPane runner integration", () => {
const directory = "/tmp/omo-project/(pane)"
// when
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0")
const result = await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0", "-h", createDeps())
// then
const firstCall = getRunTmuxCommandCall(0)
@@ -125,7 +123,7 @@ describe("spawnTmuxPane runner integration", () => {
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0")
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain("--dir '/path with spaces/here'")
@@ -136,7 +134,7 @@ describe("spawnTmuxPane runner integration", () => {
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0")
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain(`--dir '${process.cwd()}'`)
@@ -147,7 +145,7 @@ describe("spawnTmuxPane runner integration", () => {
const spawnTmuxPane = await loadSpawnTmuxPane()
// when
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0")
await spawnTmuxPane("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", "-h", createDeps())
// then
expect(getSplitWindowCommand()).toContain("--dir '/path/with'\\''quote'")
+31 -7
View File
@@ -1,11 +1,36 @@
import type { TmuxConfig } from "../../../config/schema"
import { getTmuxPath } from "../../../tools/interactive-bash/tmux-path-resolver"
import type { SpawnPaneResult } from "../types"
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
import type { SplitDirection } from "./environment"
import { isInsideTmux } from "./environment"
import { isServerRunning } from "./server-health"
import { shellSingleQuote } from "../../shell-env"
type SpawnTmuxPaneDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxPaneDeps(deps?: Partial<SpawnTmuxPaneDeps>): Promise<SpawnTmuxPaneDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export async function spawnTmuxPane(
sessionId: string,
description: string,
@@ -14,11 +39,10 @@ export async function spawnTmuxPane(
directory: string,
targetPaneId?: string,
splitDirection: SplitDirection = "-h",
depsInput?: Partial<SpawnTmuxPaneDeps>,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
const deps = await resolveSpawnTmuxPaneDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxPane] called", {
sessionId,
@@ -33,18 +57,18 @@ export async function spawnTmuxPane(
log("[spawnTmuxPane] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxPane] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxPane] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxPane] SKIP: tmux not found")
return { success: false }
@@ -4,11 +4,6 @@ import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const sessionSpawnSpecifier = import.meta.resolve("./session-spawn")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const serverHealthSpecifier = import.meta.resolve("./server-health")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const enabledTmuxConfig = {
enabled: true,
@@ -28,7 +23,7 @@ const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
@@ -64,23 +59,24 @@ function getSpawnCommand(): string {
return newSessionCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./session-spawn").spawnTmuxSession>[6]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxSession(): Promise<typeof import("./session-spawn").spawnTmuxSession> {
const module = await import(`${sessionSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxSession
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(serverHealthSpecifier, () => ({ isServerRunning: isServerRunningMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("spawnTmuxSession runner integration", () => {
beforeEach(() => {
mock.restore()
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
@@ -111,7 +107,7 @@ describe("spawnTmuxSession runner integration", () => {
const directory = "/tmp/omo-project/(session)"
// when
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0")
const result = await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, "%0", createDeps())
// then
const displayCall = getRunTmuxCommandCall(0)
@@ -134,7 +130,7 @@ describe("spawnTmuxSession runner integration", () => {
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0")
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain("--dir '/path with spaces/here'")
@@ -145,7 +141,7 @@ describe("spawnTmuxSession runner integration", () => {
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0")
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain(`--dir '${process.cwd()}'`)
@@ -156,7 +152,7 @@ describe("spawnTmuxSession runner integration", () => {
const spawnTmuxSession = await loadSpawnTmuxSession()
// when
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0")
await spawnTmuxSession("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", "%0", createDeps())
// then
expect(getSpawnCommand()).toContain("--dir '/path/with'\\''quote'")
+30 -7
View File
@@ -8,6 +8,30 @@ import { shellSingleQuote } from "../../shell-env"
const ISOLATED_SESSION_NAME_PREFIX = "omo-agents"
type SpawnTmuxSessionDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxSessionDeps(deps?: Partial<SpawnTmuxSessionDeps>): Promise<SpawnTmuxSessionDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export function getIsolatedSessionName(pid: number = process.pid): string {
return `${ISOLATED_SESSION_NAME_PREFIX}-${pid}`
}
@@ -39,11 +63,10 @@ export async function spawnTmuxSession(
serverUrl: string,
directory: string,
sourcePaneId?: string,
depsInput?: Partial<SpawnTmuxSessionDeps>,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
const deps = await resolveSpawnTmuxSessionDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxSession] called", {
sessionId,
@@ -56,18 +79,18 @@ export async function spawnTmuxSession(
log("[spawnTmuxSession] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxSession] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxSession] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxSession] SKIP: tmux not found")
return { success: false }
+15 -19
View File
@@ -4,11 +4,6 @@ import type { TmuxConfig } from "../../../config/schema"
import type { TmuxCommandResult } from "../runner"
const windowSpawnSpecifier = import.meta.resolve("./window-spawn")
const environmentSpecifier = import.meta.resolve("./environment")
const loggerSpecifier = import.meta.resolve("../../logger")
const runnerSpecifier = import.meta.resolve("../runner")
const serverHealthSpecifier = import.meta.resolve("./server-health")
const tmuxPathResolverSpecifier = import.meta.resolve("../../../tools/interactive-bash/tmux-path-resolver")
const enabledTmuxConfig = {
enabled: true,
@@ -28,7 +23,7 @@ const runTmuxCommandMock = mock(async (): Promise<TmuxCommandResult> => ({
}))
const isInsideTmuxMock = mock((): boolean => true)
const isServerRunningMock = mock(async (): Promise<boolean> => true)
const getTmuxPathMock = mock(async (): Promise<string | undefined> => "sh")
const getTmuxPathMock = mock(async (): Promise<string | null> => "sh")
const logMock = mock(() => undefined)
function toStringArray(value: unknown): string[] {
@@ -64,23 +59,24 @@ function getNewWindowCommand(): string {
return newWindowCommand
}
function createDeps(): NonNullable<Parameters<typeof import("./window-spawn").spawnTmuxWindow>[5]> {
return {
log: logMock,
runTmuxCommand: runTmuxCommandMock,
isInsideTmux: isInsideTmuxMock,
isServerRunning: isServerRunningMock,
getTmuxPath: getTmuxPathMock,
}
}
async function loadSpawnTmuxWindow(): Promise<typeof import("./window-spawn").spawnTmuxWindow> {
const module = await import(`${windowSpawnSpecifier}?test=${crypto.randomUUID()}`)
return module.spawnTmuxWindow
}
function registerModuleMocks(): void {
mock.module(environmentSpecifier, () => ({ isInsideTmux: isInsideTmuxMock }))
mock.module(loggerSpecifier, () => ({ log: logMock }))
mock.module(runnerSpecifier, () => ({ runTmuxCommand: runTmuxCommandMock }))
mock.module(serverHealthSpecifier, () => ({ isServerRunning: isServerRunningMock }))
mock.module(tmuxPathResolverSpecifier, () => ({ getTmuxPath: getTmuxPathMock }))
}
describe("spawnTmuxWindow runner integration", () => {
beforeEach(() => {
mock.restore()
registerModuleMocks()
runTmuxCommandMock.mockClear()
isInsideTmuxMock.mockClear()
isServerRunningMock.mockClear()
@@ -109,7 +105,7 @@ describe("spawnTmuxWindow runner integration", () => {
const directory = "/tmp/omo-project/(window)"
// when
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory)
const result = await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", directory, createDeps())
// then
const firstCall = getRunTmuxCommandCall(0)
@@ -125,7 +121,7 @@ describe("spawnTmuxWindow runner integration", () => {
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here")
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path with spaces/here", createDeps())
// then
expect(getNewWindowCommand()).toContain("--dir '/path with spaces/here'")
@@ -136,7 +132,7 @@ describe("spawnTmuxWindow runner integration", () => {
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "")
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "", createDeps())
// then
expect(getNewWindowCommand()).toContain(`--dir '${process.cwd()}'`)
@@ -147,7 +143,7 @@ describe("spawnTmuxWindow runner integration", () => {
const spawnTmuxWindow = await loadSpawnTmuxWindow()
// when
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote")
await spawnTmuxWindow("session-1", "worker", enabledTmuxConfig, "http://127.0.0.1:1234", "/path/with'quote", createDeps())
// then
expect(getNewWindowCommand()).toContain("--dir '/path/with'\\''quote'")
+31 -7
View File
@@ -4,20 +4,44 @@ import type { SpawnPaneResult } from "../types"
import { isInsideTmux } from "./environment"
import { isServerRunning } from "./server-health"
import { shellSingleQuote } from "../../shell-env"
import type { runTmuxCommand as RunTmuxCommand } from "../runner"
const ISOLATED_WINDOW_NAME = "omo-agents"
type SpawnTmuxWindowDeps = {
log: (message: string, data?: unknown) => void
runTmuxCommand: typeof RunTmuxCommand
isInsideTmux: typeof isInsideTmux
isServerRunning: typeof isServerRunning
getTmuxPath: typeof getTmuxPath
}
async function resolveSpawnTmuxWindowDeps(deps?: Partial<SpawnTmuxWindowDeps>): Promise<SpawnTmuxWindowDeps> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
return {
log,
runTmuxCommand,
isInsideTmux,
isServerRunning,
getTmuxPath,
...deps,
}
}
export async function spawnTmuxWindow(
sessionId: string,
description: string,
config: TmuxConfig,
serverUrl: string,
directory: string,
depsInput?: Partial<SpawnTmuxWindowDeps>,
): Promise<SpawnPaneResult> {
const [{ log }, { runTmuxCommand }] = await Promise.all([
import("../../logger"),
import("../runner"),
])
const deps = await resolveSpawnTmuxWindowDeps(depsInput)
const { log, runTmuxCommand } = deps
log("[spawnTmuxWindow] called", {
sessionId,
@@ -30,18 +54,18 @@ export async function spawnTmuxWindow(
log("[spawnTmuxWindow] SKIP: config.enabled is false")
return { success: false }
}
if (!isInsideTmux()) {
if (!deps.isInsideTmux()) {
log("[spawnTmuxWindow] SKIP: not inside tmux", { TMUX: process.env.TMUX })
return { success: false }
}
const serverRunning = await isServerRunning(serverUrl)
const serverRunning = await deps.isServerRunning(serverUrl)
if (!serverRunning) {
log("[spawnTmuxWindow] SKIP: server not running", { serverUrl })
return { success: false }
}
const tmux = await getTmuxPath()
const tmux = await deps.getTmuxPath()
if (!tmux) {
log("[spawnTmuxWindow] SKIP: tmux not found")
return { success: false }
@@ -1,11 +1,5 @@
/// <reference types="bun-types" />
import { describe, test, expect, mock } from "bun:test"
mock.module("../../shared/frontmatter", () => ({
parseFrontmatter: () => ({ frontmatter: {}, content: "" }),
}))
mock.module("js-yaml", () => ({
load: () => ({}),
}))
import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin"
import { executeBackground } from "./background-executor"
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test"
import { getMissingLookAtFilePath } from "./missing-file-error"
describe("getMissingLookAtFilePath", () => {
test("#given ENOENT error with path property #when formatting look_at error #then returns missing path", () => {
//#given
const error = new Error("ENOENT: no such file or directory")
Object.defineProperty(error, "code", { value: "ENOENT" })
Object.defineProperty(error, "path", { value: "/tmp/missing.png" })
//#when
const path = getMissingLookAtFilePath(error, { file_path: "/tmp/fallback.png", goal: "inspect" })
//#then
expect(path).toBe("/tmp/missing.png")
})
test("#given ENOENT message without path property #when formatting look_at error #then extracts open path", () => {
//#given
const error = new Error("ENOENT: no such file or directory, open '/tmp/from-message.png'")
//#when
const path = getMissingLookAtFilePath(error, { file_path: "/tmp/fallback.png", goal: "inspect" })
//#then
expect(path).toBe("/tmp/from-message.png")
})
})
+45
View File
@@ -0,0 +1,45 @@
import type { LookAtArgs } from "./types"
export function getMissingLookAtFilePath(error: unknown, args: LookAtArgs): string | null {
if (!isMissingFileError(error)) {
return null
}
const pathFromError = getMissingFilePathFromError(error)
if (pathFromError) {
return pathFromError
}
return args.file_path ?? null
}
function getMissingFilePathFromError(error: unknown): string | null {
if (!(error instanceof Error)) {
return null
}
const path = Reflect.get(error, "path")
if (typeof path === "string" && path.length > 0) {
return path
}
if (error instanceof Error) {
const match = /open '([^']+)'/.exec(error.message)
return match?.[1] ?? null
}
return null
}
function isMissingFileError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false
}
const code = Reflect.get(error, "code")
if (code === "ENOENT") {
return true
}
return error.message.includes("ENOENT") && error.message.includes("no such file or directory")
}
+7
View File
@@ -6,6 +6,7 @@ import type { LookAtArgsWithAlias } from "./look-at-arguments"
import { normalizeArgs, validateArgs } from "./look-at-arguments"
import { prepareLookAtInput } from "./look-at-input-preparer"
import { runLookAtSession } from "./look-at-session-runner"
import { getMissingLookAtFilePath } from "./missing-file-error"
export { normalizeArgs, validateArgs } from "./look-at-arguments"
@@ -43,6 +44,12 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
isBase64Input,
})
} catch (error) {
const missingFilePath = getMissingLookAtFilePath(error, args)
if (missingFilePath) {
log(`[look_at] Missing file while analyzing ${sourceDescription}:`, error)
return `Error: File not found: ${missingFilePath}`
}
const errorMessage = error instanceof Error ? error.message : String(error)
log(`[look_at] Unexpected error analyzing ${sourceDescription}:`, error)
return `Error: Failed to analyze ${sourceDescription}: ${errorMessage}`